diff --git a/src/app/(quiz)/quiz/[quizSetId]/play/page.tsx b/src/app/(quiz)/quiz/[quizSetId]/play/page.tsx index 55d3b49..a42f962 100644 --- a/src/app/(quiz)/quiz/[quizSetId]/play/page.tsx +++ b/src/app/(quiz)/quiz/[quizSetId]/play/page.tsx @@ -107,7 +107,6 @@ export default function QuizPlayPage() { })), }); - // setLastSubmit(submitRes as any); setLastSubmit(submitRes); router.push(`/quiz/${naverArticleId}/result`); } catch { diff --git a/src/app/(quiz)/quiz/[quizSetId]/result/next/page.tsx b/src/app/(quiz)/quiz/[quizSetId]/result/next/page.tsx index fb2bf5a..8856fa4 100644 --- a/src/app/(quiz)/quiz/[quizSetId]/result/next/page.tsx +++ b/src/app/(quiz)/quiz/[quizSetId]/result/next/page.tsx @@ -4,75 +4,114 @@ import Image from "next/image"; import { useRouter, useParams } from "next/navigation"; import { useState } from "react"; +import { NewCategoryBottomSheet } from "@/components/study/NewCategoryBottomSheet"; +import { + saveNewsToStorage, + getStorageFoldersByItemId, +} from "@/lib/api/storage"; + import NextActionCard from "@/components/quiz/NextActionCard"; +type Category = { + category_id: number; + name: string; +}; + +const ARCHIVE_CATEGORIES: Category[] = [{ category_id: 0, name: "기본 폴더" }]; + export default function QuizNextActionPage() { const router = useRouter(); const params = useParams<{ quizSetId: string }>(); + + const newsId = params.quizSetId; // 뉴스 ID로 사용 const quizSetId = Number(params.quizSetId); - const [sheetOpen, setSheetOpen] = useState(false); + const [isSaved, setIsSaved] = useState(false); + const [isSaveBottomSheetOpen, setIsSaveBottomSheetOpen] = useState(false); + + // 폴더 선택 시 저장 + const handleSelectCategory = async (categoryId: number | null) => { + if (!newsId || categoryId === null) return; + + try { + const articleId = parseInt(newsId, 10); + + await saveNewsToStorage(articleId, [categoryId]); + + const response = await getStorageFoldersByItemId(articleId, "NEWS"); + setIsSaved(response.data.length > 0); + + setIsSaveBottomSheetOpen(false); + } catch (err) { + console.error("저장 실패:", err); + setIsSaveBottomSheetOpen(false); + } + }; return (
-
-
- {/* 캐릭터 */} -
- -
- - {/* 타이틀 */} -

- 다음 할 일을 골라보세요! -

- - {/* 옵션 4개 */} -
- setSheetOpen(true)} - /> - - { - router.push(`/quiz/${quizSetId}`); - }} - /> - - router.push("/study")} - /> - - router.push("/archive")} - /> -
-
- - {/* 바텀시트 추가 예정*/} -
+
+ {/* 캐릭터 */} +
+ +
+ + {/* 타이틀 */} +

+ 다음 할 일을 골라보세요! +

+ + {/* 옵션 */} +
+ setIsSaveBottomSheetOpen(true)} + /> + + router.push(`/quiz/${quizSetId}`)} + /> + + router.push("/study")} + /> + + router.push("/archive")} + /> +
+
+ + {/* 보관함 저장 바텀시트 */} + {}} + itemId={newsId ? parseInt(newsId, 10) : undefined} + />
); } diff --git a/src/app/study/[id]/page.tsx b/src/app/study/[id]/page.tsx index a42fdfd..af9d12a 100644 --- a/src/app/study/[id]/page.tsx +++ b/src/app/study/[id]/page.tsx @@ -91,26 +91,20 @@ export default function NewsDetailPage() { setIsSaveBottomSheetOpen(true); }; - // 기사 저장용 핸들러 - const handleSelectCategory = async (categoryId: number | null) => { - if (!newsId || categoryId === null) { - return; - } - - try { - // 선택한 폴더에 뉴스 저장 API 호출 - const articleId = parseInt(newsId, 10); - await saveNewsToStorage(articleId, [categoryId]); - // 저장 후 저장된 폴더 목록 다시 조회하여 북마크 상태 업데이트 + // 기사 저장/삭제 완료 후 북마크 상태 업데이트 + const handleToggleComplete = async () => { + const articleId = parseInt(newsId, 10); + if (!isNaN(articleId)) { await fetchSavedFolders(articleId); - setIsSaveBottomSheetOpen(false); - } catch (err) { - console.error("뉴스 저장 실패:", err); - // 에러 발생 시에도 바텀시트는 닫음 (사용자 경험을 위해) - setIsSaveBottomSheetOpen(false); } }; + // 기사 저장용 핸들러 (호환성 유지, 실제로는 NewCategoryBottomSheet에서 처리) + const handleSelectCategory = async (categoryId: number | null) => { + // NewCategoryBottomSheet에서 내부적으로 처리하므로 여기서는 아무것도 하지 않음 + // 필요시 추가 로직 구현 가능 + }; + const handleAddNewCategory = () => { // TODO: 새 카테고리 추가 기능 구현 console.log("새 카테고리 추가"); @@ -461,7 +455,9 @@ export default function NewsDetailPage() { categories={ARCHIVE_CATEGORIES} onSelectCategory={handleSelectCategory} onAddNewCategory={handleAddNewCategory} + onToggleComplete={handleToggleComplete} itemId={newsId ? parseInt(newsId, 10) : undefined} + folderType="NEWS" /> {/* 단어 설명 카드 */} diff --git a/src/components/Button.tsx b/src/components/Button.tsx index c566ca8..118c3dd 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -1,47 +1,29 @@ -'use client'; - -import React from 'react'; +"use client"; interface ButtonProps { text: string; onClick: () => void; disabled?: boolean; + className?: string; } -export default function Button({ - text, - onClick, - disabled = false +export default function Button({ + text, + onClick, + disabled = false, + className = "", }: ButtonProps) { return ( -
- -
+ {text} + ); -} \ No newline at end of file +} diff --git a/src/components/mypage/PasswordChangeScreen.tsx b/src/components/mypage/PasswordChangeScreen.tsx index 7826993..4d9614e 100644 --- a/src/components/mypage/PasswordChangeScreen.tsx +++ b/src/components/mypage/PasswordChangeScreen.tsx @@ -93,71 +93,76 @@ export default function PasswordChangeScreen({ email }: Props) { } return ( -
+
-
-

현재 비밀번호

-
- { - setCurrentPw(v); - if (submitError) setSubmitError(null); - }} - /> -
- - {submitError ? ( -

{submitError}

- ) : null} -
- -
-

새 비밀번호

-
- -
- -

- {newPwValid === null - ? "" - : newPwValid - ? "사용가능 비밀번호입니다." - : "사용불가 비밀번호입니다. (영문, 숫자 6~18자리)"} -

-
- -
-

새 비밀번호 확인

-
- -
- -

- {confirmMatch === null - ? "" - : confirmMatch - ? "비밀번호가 일치합니다." - : "비밀번호가 일치하지 않습니다."} -

-
+ {/* 입력 영역 */} +
+
+

현재 비밀번호

+
+ { + setCurrentPw(v); + if (submitError) setSubmitError(null); + }} + /> +
+ + {submitError ? ( +

{submitError}

+ ) : null} +
+ +
+

새 비밀번호

+
+ +
+ +

+ {newPwValid === null + ? "" + : newPwValid + ? "사용가능 비밀번호입니다." + : "사용불가 비밀번호입니다. (영문, 숫자 6~18자리)"} +

+
+ +
+

새 비밀번호 확인

+
+ +
+ +

+ {confirmMatch === null + ? "" + : confirmMatch + ? "비밀번호가 일치합니다." + : "비밀번호가 일치하지 않습니다."} +

+
+
{/* 저장 버튼 */} -
); } diff --git a/src/components/mypage/ProfileEditScreen.tsx b/src/components/mypage/ProfileEditScreen.tsx index 4fefa4a..b6d63a9 100644 --- a/src/components/mypage/ProfileEditScreen.tsx +++ b/src/components/mypage/ProfileEditScreen.tsx @@ -5,6 +5,7 @@ import PageHeader from "@/components/mypage/PageHeader"; import ConfirmModal from "@/components/mypage/ConfirmModal"; import NicknameField from "@/components/mypage/NicknameField"; import CategoryPicker from "@/components/mypage/CategoryPicker"; +import Button from "@/components/Button"; import { getMyProfile, getUserCategories, @@ -109,36 +110,33 @@ export default function ProfileEditScreen() { } return ( -
+
- - - setEditMode((v) => !v)} - /> +
+ + + setEditMode((v) => !v)} + /> +
{/* 저장 버튼 */} -
- + />
- +
diff --git a/src/components/quiz/QuizResultItem.tsx b/src/components/quiz/QuizResultItem.tsx index 866fe22..405c68c 100644 --- a/src/components/quiz/QuizResultItem.tsx +++ b/src/components/quiz/QuizResultItem.tsx @@ -35,7 +35,7 @@ export default function QuizResultItem({ kind, title, onClick }: Props) { />
-

+

{qLabel && {qLabel}} {restText}

diff --git a/src/components/study/NewCategoryBottomSheet.tsx b/src/components/study/NewCategoryBottomSheet.tsx index 8b2c55c..0f6a6e8 100644 --- a/src/components/study/NewCategoryBottomSheet.tsx +++ b/src/components/study/NewCategoryBottomSheet.tsx @@ -1,12 +1,12 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useMemo } from "react"; import { Sheet, SheetContent, SheetTitle, } from "@/components/ui/sheet"; -import { getStorageFolders, createStorageFolder, getStorageFoldersByItemId, type StorageFolder } from "@/lib/api/storage"; +import { getStorageFolders, createStorageFolder, getStorageFoldersByItemId, getStorageNews, deleteNewsFromStorage, saveNewsToStorage, getStorageTerms, deleteTermFromStorage, saveTermToStorage, type StorageFolder } from "@/lib/api/storage"; type Category = { category_id: number; @@ -18,12 +18,13 @@ type NewCategoryBottomSheetProps = { open: boolean; onOpenChange: (open: boolean) => void; categories: Category[]; - onSelectCategory: (categoryId: number | null) => void; + onSelectCategory?: (categoryId: number | null) => void; // 선택적: 내부에서 처리할 수도 있음 onAddNewCategory: () => void; itemId?: number; // 뉴스 ID 또는 단어 ID folderType?: "NEWS" | "TERM"; // 폴더 타입 title?: string; // 바텀시트 제목 subtitle?: string; // 바텀시트 부제목 + onToggleComplete?: () => void; // 저장/삭제 완료 후 콜백 }; // 새 카테고리 생성 폼 컴포넌트 @@ -222,14 +223,22 @@ export function NewCategoryBottomSheet({ folderType = "NEWS", title, subtitle, + onToggleComplete, }: NewCategoryBottomSheetProps) { const [isCreatingNewCategory, setIsCreatingNewCategory] = useState(false); const [newCategoryName, setNewCategoryName] = useState(""); const [folders, setFolders] = useState([]); + const [optimisticFolders, setOptimisticFolders] = useState(null); const [loading, setLoading] = useState(false); const [savedFolderIds, setSavedFolderIds] = useState([]); - // 폴더 목록 조회 함수 + // displayCategories: optimisticFolders가 있으면 사용, 없으면 folders가 있으면 folders 사용, 없으면 categories prop 사용 + const displayCategories = useMemo(() => { + if (optimisticFolders) return optimisticFolders; + return folders.length > 0 ? folders : categories; + }, [optimisticFolders, folders, categories]); + + // 폴더 목록 조회 함수 (로딩 상태 포함) const fetchFolders = useCallback(async () => { try { setLoading(true); @@ -240,14 +249,33 @@ export function NewCategoryBottomSheet({ count: folder.itemCount, })); setFolders(folderList); + return response; } catch (err) { console.warn("폴더 목록 조회 실패:", err); setFolders([]); + throw err; } finally { setLoading(false); } }, [folderType]); + // 폴더 목록 조회 함수 (백그라운드 동기화용, 로딩 상태 없음) + const syncFolders = useCallback(async () => { + try { + const response = await getStorageFolders(folderType); + const folderList: Category[] = response.data.map((folder: StorageFolder) => ({ + category_id: folder.folderId, + name: folder.folderName, + count: folder.itemCount, + })); + setFolders(folderList); + return response; + } catch (err) { + console.warn("폴더 목록 동기화 실패:", err); + throw err; + } + }, [folderType]); + // 저장된 폴더 목록 조회 함수 const fetchSavedFolders = useCallback(async () => { if (!itemId) { @@ -281,9 +309,117 @@ export function NewCategoryBottomSheet({ } }, [open]); - const handleCategorySelect = (categoryId: number | null) => { - onSelectCategory(categoryId); - onOpenChange(false); + // 폴더에서 아이템 삭제 (savedItemId 찾기) + const findSavedItemId = useCallback(async (folderId: number): Promise => { + if (!itemId) return null; + + try { + if (folderType === "NEWS") { + // 뉴스의 경우: 폴더의 뉴스 목록에서 해당 newsId 찾기 + const response = await getStorageNews({ folderId, size: 1000 }); + const savedItem = response.data.news.find((item) => item.newsId === itemId); + return savedItem?.savedItemId || null; + } else { + // 용어의 경우: 폴더의 용어 목록에서 해당 termId 찾기 + const response = await getStorageTerms({ folderId, size: 1000 }); + const savedItem = response.data.terms.find((item) => item.termId === itemId); + return savedItem?.savedItemId || null; + } + } catch (err) { + console.error("savedItemId 조회 실패:", err); + return null; + } + }, [itemId, folderType]); + + const handleCategorySelect = async (categoryId: number | null) => { + if (!itemId || categoryId === null) { + if (onSelectCategory) { + onSelectCategory(categoryId); + } + onOpenChange(false); + return; + } + + const isSaved = savedFolderIds.includes(categoryId); + + // Optimistic update: 로컬 상태 먼저 업데이트 + const prevSavedFolderIds = [...savedFolderIds]; + const currentDisplayCategories = displayCategories; + const prevDisplayCategories = [...currentDisplayCategories]; + + if (isSaved) { + // UI 먼저 업데이트 (삭제) + setSavedFolderIds((prev) => prev.filter((id) => id !== categoryId)); + setOptimisticFolders( + currentDisplayCategories.map((category) => + category.category_id === categoryId + ? { ...category, count: Math.max(0, (category.count || 0) - 1) } + : category + ) + ); + } else { + // UI 먼저 업데이트 (저장) + setSavedFolderIds((prev) => [...prev, categoryId]); + setOptimisticFolders( + currentDisplayCategories.map((category) => + category.category_id === categoryId + ? { ...category, count: (category.count || 0) + 1 } + : category + ) + ); + } + + try { + if (isSaved) { + // 이미 저장된 폴더면 삭제 + const savedItemId = await findSavedItemId(categoryId); + if (savedItemId) { + if (folderType === "NEWS") { + await deleteNewsFromStorage(savedItemId); + } else { + await deleteTermFromStorage(savedItemId); + } + } + } else { + // 저장되지 않은 폴더면 저장 + if (folderType === "NEWS") { + await saveNewsToStorage(itemId, [categoryId]); + } else { + await saveTermToStorage(itemId, [categoryId]); + } + } + + // 백그라운드에서 동기화 (에러가 나도 UI는 이미 업데이트됨, 로딩 상태 없이 조용히 업데이트) + Promise.all([ + fetchSavedFolders(), + syncFolders(), + ]).then(() => { + // 동기화 완료 후 optimistic 상태 초기화 + setOptimisticFolders(null); + }).catch((err) => { + console.error("동기화 실패:", err); + // 에러 발생 시 이전 상태로 롤백 + setSavedFolderIds(prevSavedFolderIds); + setOptimisticFolders(prevDisplayCategories); + }); + + // 기존 onSelectCategory 콜백 호출 (호환성 유지) + if (onSelectCategory) { + onSelectCategory(categoryId); + } + + // 완료 콜백 호출 + if (onToggleComplete) { + onToggleComplete(); + } + + // 바텀시트는 닫지 않음 (여러 폴더 선택 가능하도록) + } catch (err) { + console.error("폴더 저장/삭제 실패:", err); + // 에러 발생 시 이전 상태로 롤백 + setSavedFolderIds(prevSavedFolderIds); + setOptimisticFolders(prevDisplayCategories); + } }; const handleAddNewCategoryClick = () => { @@ -328,7 +464,7 @@ export function NewCategoryBottomSheet({ /> ) : ( 0 ? displayCategories : folders.length > 0 ? folders : categories} onSelectCategory={handleCategorySelect} onAddNewCategory={handleAddNewCategoryClick} loading={loading} diff --git a/src/components/study/TermDescriptionCard.tsx b/src/components/study/TermDescriptionCard.tsx index 0ffbf05..fcb67ec 100644 --- a/src/components/study/TermDescriptionCard.tsx +++ b/src/components/study/TermDescriptionCard.tsx @@ -7,6 +7,8 @@ import { createStorageFolder, saveTermToStorage, getStorageFoldersByItemId, + getStorageTerms, + deleteTermFromStorage, type StorageFolder, } from "@/lib/api/storage"; @@ -40,7 +42,7 @@ export function TermDescriptionCard({ const [savedFolderIds, setSavedFolderIds] = useState([]); // 저장된 폴더 ID 목록 const [isSaving, setIsSaving] = useState(false); // 단어 저장 중 상태 (중복 클릭 방지용) - // 사용자 폴더 목록 조회 함수 + // 사용자 폴더 목록 조회 함수 (로딩 상태 포함) const fetchUserFolders = useCallback(async () => { try { setLoadingFolders(true); @@ -53,6 +55,16 @@ export function TermDescriptionCard({ } }, []); + // 사용자 폴더 목록 조회 함수 (백그라운드 동기화용, 로딩 상태 없음) + const syncUserFolders = useCallback(async () => { + try { + const response = await getStorageFolders("TERM"); + setUserFolders(response.data); + } catch (err) { + console.error("보관함 폴더 동기화 실패:", err); + } + }, []); + // 저장된 폴더 목록 조회 함수 const fetchSavedFolders = useCallback(async () => { if (!term?.termId) { @@ -88,6 +100,21 @@ export function TermDescriptionCard({ setIsSaving(false); }, [term?.termId]); + // 폴더에서 용어 삭제 (savedItemId 찾기) + const findSavedItemId = useCallback(async (folderId: number): Promise => { + if (!term?.termId) return null; + + try { + // 폴더의 용어 목록에서 해당 termId 찾기 + const response = await getStorageTerms({ folderId, size: 1000 }); + const savedItem = response.data.terms.find((item) => item.termId === term.termId); + return savedItem?.savedItemId || null; + } catch (err) { + console.error("savedItemId 조회 실패:", err); + return null; + } + }, [term?.termId]); + if (!term) return null; // 단어 설명 데이터가 없으면 카드 렌더링하지 않음 // 보관함에 저장하기 버튼 클릭 시 카드 모드 변경 @@ -105,21 +132,29 @@ export function TermDescriptionCard({ } }; - // 카테고리 선택 시 보관함에 단어 저장 + // 카테고리 선택 시 보관함에 단어 저장/삭제 const handleCategorySelect = async (folderId: number | null) => { if (!term?.termId || folderId === null || isSaving) return; - if (savedFolderIds.includes(folderId)) return; - try { - setIsSaving(true); - - // 보관함에 단어 저장 API 호출 - await saveTermToStorage(term.termId, [folderId]); - - // 저장된 폴더 ID 목록 업데이트 (즉각적인 UI 업데이트를 위해 API 호출 없이 로컬 상태만 변경) + const isSaved = savedFolderIds.includes(folderId); + + // Optimistic update: 로컬 상태 먼저 업데이트 + const prevSavedFolderIds = [...savedFolderIds]; + const prevUserFolders = [...userFolders]; + + if (isSaved) { + // UI 먼저 업데이트 (삭제) + setSavedFolderIds((prev) => prev.filter((id) => id !== folderId)); + setUserFolders((prevFolders) => + prevFolders.map((folder) => + folder.folderId === folderId + ? { ...folder, itemCount: Math.max(0, folder.itemCount - 1) } + : folder + ) + ); + } else { + // UI 먼저 업데이트 (저장) setSavedFolderIds((prev) => [...prev, folderId]); - - // 선택한 폴더의 itemCount를 로컬에서 증가시킴 setUserFolders((prevFolders) => prevFolders.map((folder) => folder.folderId === folderId @@ -127,11 +162,40 @@ export function TermDescriptionCard({ : folder ) ); + } + + try { + setIsSaving(true); + + if (isSaved) { + // 이미 저장된 폴더면 삭제 + const savedItemId = await findSavedItemId(folderId); + if (savedItemId) { + await deleteTermFromStorage(savedItemId); + } + } else { + // 저장되지 않은 폴더면 저장 + await saveTermToStorage(term.termId, [folderId]); + } + + // 백그라운드에서 동기화 (에러가 나도 UI는 이미 업데이트됨, 로딩 상태 없이 조용히 업데이트) + Promise.all([ + fetchSavedFolders(), + syncUserFolders(), + ]).catch((err) => { + console.error("동기화 실패:", err); + // 에러 발생 시 이전 상태로 롤백 + setSavedFolderIds(prevSavedFolderIds); + setUserFolders(prevUserFolders); + }); // 부모 컴포넌트에 알림 (필요한 경우에만) onSelectCategory?.(folderId); } catch (err) { - console.error("보관함에 단어 저장 실패:", err); + console.error("보관함에 단어 저장/삭제 실패:", err); + // 에러 발생 시 이전 상태로 롤백 + setSavedFolderIds(prevSavedFolderIds); + setUserFolders(prevUserFolders); } finally { setIsSaving(false); }