diff --git a/api/categories.ts b/api/categories.ts new file mode 100644 index 0000000..21e52f4 --- /dev/null +++ b/api/categories.ts @@ -0,0 +1,71 @@ +import { + authenticatedApiRequest, + type ApiRequestOptions, + type ClerkTokenGetter, +} from '@/api/api-client'; + +export type CategoryResponse = { + id: number; + name: string; + displayOrder: number; + linkCount: number; + createdAt: string; +}; + +export type CategoryListResponse = { + items: CategoryResponse[]; +}; + +export type CategoryCreateRequest = { + name: string; + linkIds?: number[]; +}; + +export type CategoryRenameResponse = { + id: number; + name: string; +}; + +type CategoryRequestOptions = Pick; + +export function createCategory( + getToken: ClerkTokenGetter, + body: CategoryCreateRequest, + options: CategoryRequestOptions = {}, +) { + return authenticatedApiRequest(getToken, '/api/v1/categories', { + ...options, + method: 'POST', + body, + }); +} + +export function fetchCategories( + getToken: ClerkTokenGetter, + options: CategoryRequestOptions = {}, +) { + return authenticatedApiRequest( + getToken, + '/api/v1/categories', + options, + ); +} + +export function renameCategory(getToken: ClerkTokenGetter, id: number, name: string) { + return authenticatedApiRequest( + getToken, + `/api/v1/categories/${encodeURIComponent(String(id))}`, + { + method: 'PATCH', + body: { name }, + }, + ); +} + +export function deleteCategory(getToken: ClerkTokenGetter, id: number) { + return authenticatedApiRequest( + getToken, + `/api/v1/categories/${encodeURIComponent(String(id))}`, + { method: 'DELETE' }, + ); +} diff --git a/app/(tabs)/(folder)/[id].tsx b/app/(tabs)/(folder)/[id].tsx index 41863c2..b920ebf 100644 --- a/app/(tabs)/(folder)/[id].tsx +++ b/app/(tabs)/(folder)/[id].tsx @@ -1,4 +1,4 @@ -import { ScrollView, StyleSheet, Text, View } from 'react-native'; +import { Alert, ScrollView, StyleSheet, Text, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useLocalSearchParams, useRouter } from 'expo-router'; @@ -6,9 +6,10 @@ import { AddFolderButton } from '@/components/ui/add-folder-button'; import { AppIcon } from '@/components/ui/app-icon'; import { CardLink } from '@/components/ui/card-link'; import { FolderContextMenu } from '@/components/ui/folder-context-menu'; +import { TitleEditModal } from '@/components/ui/title-edit-modal'; import { Toast } from '@/components/ui/toast'; import { Colors, Typography } from '@/constants/theme'; -import { useSavedLinks } from '@/context/saved-links-context'; +import { getSavedLinkErrorMessage, useSavedLinks, type SavedLink } from '@/context/saved-links-context'; import { useFolders } from '@/context/folders-context'; import type { AnchorPosition } from '@/components/ui/folder-card'; import { useEffect, useState } from 'react'; @@ -24,14 +25,16 @@ export default function FolderDetailScreen() { const router = useRouter(); const folderId = Number(id); - const { links, toggleBookmark, assignCategory } = useSavedLinks(); - const { folders } = useFolders(); + const { links, toggleBookmark, assignCategory, updateTitle } = useSavedLinks(); + const { folders, refreshFolders } = useFolders(); const folderLinks = links.filter((l) => l.categoryId === folderId); const folderName = folders.find((f) => f.id === folderId)?.name ?? '폴더'; const [menuState, setMenuState] = useState({ visible: false }); + const [editingLink, setEditingLink] = useState(null); const [toastVisible, setToastVisible] = useState(false); const [addToastVisible, setAddToastVisible] = useState(false); + const [titleToastVisible, setTitleToastVisible] = useState(false); useEffect(() => { if (urlAdded === '1') setAddToastVisible(true); @@ -41,10 +44,34 @@ export default function FolderDetailScreen() { setMenuState({ visible: true, anchor, linkId }); }; - const handleDelete = (linkId: number) => { - // API: PATCH /api/v1/saved-links/{id} { categoryId: null } - assignCategory([linkId], null); - setToastVisible(true); + const handleDelete = async (linkId: number) => { + try { + await assignCategory([linkId], null); + await refreshFolders(); + setToastVisible(true); + } catch (error) { + Alert.alert( + '폴더에서 삭제 실패', + getSavedLinkErrorMessage(error, '링크를 폴더에서 제외하지 못했습니다. 잠시 후 다시 시도해주세요.'), + ); + } + }; + + const confirmDeleteFromFolder = (linkId: number) => { + Alert.alert( + '폴더에서 삭제할까요?', + '링크는 삭제되지 않아요.\n현재 폴더에서만 제외돼요.', + [ + { text: '취소', style: 'cancel' }, + { + text: '폴더에서 삭제', + style: 'destructive', + onPress: () => { + void handleDelete(linkId); + }, + }, + ], + ); }; const handleAddUrl = () => { @@ -56,6 +83,11 @@ export default function FolderDetailScreen() { const currentLink = folderLinks.find((l) => l.id === menuState.linkId); + const handleTitleConfirm = async (linkId: number, title: string) => { + await updateTitle(linkId, title); + setTitleToastVisible(true); + }; + return ( {/* 헤더 */} @@ -122,11 +154,10 @@ export default function FolderDetailScreen() { anchor={menuState.anchor} items={[ { - label: currentLink?.isBookmarked ? '북마크 해제' : '북마크 추가', + label: '제목 수정', onPress: () => { - if (menuState.linkId == null) return; - // API: PATCH /saved-links/{id}/bookmark - toggleBookmark(menuState.linkId); + if (!currentLink) return; + setEditingLink(currentLink); }, }, { @@ -134,13 +165,26 @@ export default function FolderDetailScreen() { destructive: true, onPress: () => { if (menuState.linkId == null) return; - // API: PATCH /api/v1/saved-links/{id} { categoryId: null } - handleDelete(menuState.linkId); + confirmDeleteFromFolder(menuState.linkId); }, }, ]} onDismiss={() => setMenuState({ visible: false })} /> + + setEditingLink(null)} + /> + + setTitleToastVisible(false)} + /> ); } diff --git a/app/(tabs)/(folder)/_layout.tsx b/app/(tabs)/(folder)/_layout.tsx index 33d6b5f..ee0eea2 100644 --- a/app/(tabs)/(folder)/_layout.tsx +++ b/app/(tabs)/(folder)/_layout.tsx @@ -1,14 +1,5 @@ import { Stack } from 'expo-router'; -import { FoldersProvider } from '@/context/folders-context'; -import { SavedLinksProvider } from '@/context/saved-links-context'; - export default function FolderLayout() { - return ( - - - - - - ); + return ; } diff --git a/app/(tabs)/(folder)/folder-add-url.tsx b/app/(tabs)/(folder)/folder-add-url.tsx index 9576d69..c83d4d2 100644 --- a/app/(tabs)/(folder)/folder-add-url.tsx +++ b/app/(tabs)/(folder)/folder-add-url.tsx @@ -1,5 +1,6 @@ import { useCallback, useState } from 'react'; import { + Alert, FlatList, StyleSheet, Text, @@ -11,6 +12,7 @@ import { Button } from '@/components/ui/button'; import { CardLink } from '@/components/ui/card-link'; import { SelectionCircle } from '@/components/ui/selection-circle'; import { Colors, Typography } from '@/constants/theme'; +import { getFolderErrorMessage, useFolders } from '@/context/folders-context'; import { useSavedLinks, type SavedLink } from '@/context/saved-links-context'; // ─── Selectable card row ────────────────────────────────────────────────────── @@ -51,6 +53,7 @@ export default function FolderAddUrlScreen() { folderName: string; }>(); const { links, assignCategory } = useSavedLinks(); + const { refreshFolders } = useFolders(); // 미분류 URL만 표시 (categoryId === null) const uncategorizedLinks = links.filter((l) => l.categoryId === null); @@ -74,12 +77,17 @@ export default function FolderAddUrlScreen() { if (isAdding || selectedIds.size === 0) return; setIsAdding(true); try { - // PATCH /api/v1/saved-links/{id} { categoryId } — 선택한 링크들을 폴더에 추가 - assignCategory([...selectedIds], Number(folderId)); + await assignCategory([...selectedIds], Number(folderId)); + await refreshFolders(); router.replace({ pathname: '/(tabs)/(folder)/[id]', params: { id: folderId, urlAdded: '1' }, }); + } catch (error) { + Alert.alert( + 'URL 추가 실패', + getFolderErrorMessage(error, '선택한 URL을 폴더에 추가하지 못했습니다. 잠시 후 다시 시도해주세요.'), + ); } finally { setIsAdding(false); } diff --git a/app/(tabs)/(folder)/folder-name.tsx b/app/(tabs)/(folder)/folder-name.tsx index d046fd7..56775c3 100644 --- a/app/(tabs)/(folder)/folder-name.tsx +++ b/app/(tabs)/(folder)/folder-name.tsx @@ -12,17 +12,31 @@ import { router, Stack } from 'expo-router'; import { Button } from '@/components/ui/button'; import { Colors, Typography } from '@/constants/theme'; +import { useFolders } from '@/context/folders-context'; export default function FolderNameScreen() { const [folderName, setFolderName] = useState(''); const [isFocused, setIsFocused] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + const { folders } = useFolders(); const canProceed = folderName.trim().length > 0; const handleNext = () => { + const trimmedName = folderName.trim(); + const hasDuplicateName = folders.some( + (folder) => normalizeFolderName(folder.name) === normalizeFolderName(trimmedName), + ); + + if (hasDuplicateName) { + setErrorMessage('이미 같은 이름의 폴더가 있어요.'); + return; + } + + setErrorMessage(''); router.push({ pathname: '/(tabs)/(folder)/folder-url-select', - params: { folderName: folderName.trim() }, + params: { folderName: trimmedName }, }); }; @@ -57,7 +71,12 @@ export default function FolderNameScreen() { { + setFolderName(value); + if (errorMessage) { + setErrorMessage(''); + } + }} onFocus={() => setIsFocused(true)} onBlur={() => setIsFocused(false)} placeholder="폴더 이름 입력" @@ -76,6 +95,7 @@ export default function FolderNameScreen() { )} + {errorMessage ? {errorMessage} : null} {'이 이름으로 폴더가 생성되고,\n다음 단계에서 URL을 고를 수 있어요.'} @@ -168,7 +188,15 @@ const styles = StyleSheet.create({ color: Colors.brand.textSecondary, lineHeight: 20, }, + errorText: { + ...Typography.caption, + color: Colors.brand.textWarning, + }, buttonArea: { marginTop: 32, }, }); + +function normalizeFolderName(name: string) { + return name.trim().toLocaleLowerCase(); +} diff --git a/app/(tabs)/(folder)/folder-url-select.tsx b/app/(tabs)/(folder)/folder-url-select.tsx index 37ce8e7..5dd64b7 100644 --- a/app/(tabs)/(folder)/folder-url-select.tsx +++ b/app/(tabs)/(folder)/folder-url-select.tsx @@ -1,5 +1,6 @@ import { useCallback, useState } from 'react'; import { + Alert, FlatList, StyleSheet, Text, @@ -11,7 +12,7 @@ import { Button } from '@/components/ui/button'; import { CardLink } from '@/components/ui/card-link'; import { SelectionCircle } from '@/components/ui/selection-circle'; import { Colors, Typography } from '@/constants/theme'; -import { useFolders } from '@/context/folders-context'; +import { getFolderErrorMessage, useFolders } from '@/context/folders-context'; import { useSavedLinks, type SavedLink } from '@/context/saved-links-context'; // ─── Selectable card row ────────────────────────────────────────────────────── @@ -52,8 +53,9 @@ export default function FolderUrlSelectScreen() { const [selectedIds, setSelectedIds] = useState>(new Set()); const [isCreating, setIsCreating] = useState(false); const { addFolder } = useFolders(); - const { links: allLinks, assignCategory } = useSavedLinks(); + const { links: allLinks, refreshLinks } = useSavedLinks(); const links = allLinks.filter((l) => l.categoryId === null); + const name = (folderName ?? '새 폴더').trim(); const toggleSelect = useCallback((id: number) => { setSelectedIds((prev) => { @@ -71,19 +73,32 @@ export default function FolderUrlSelectScreen() { if (isCreating) return; setIsCreating(true); try { - // TODO: POST /api/v1/categories { name: folderName } 로 교체 - const newId = addFolder(folderName ?? '새 폴더'); - if (selectedIds.size > 0) { - assignCategory([...selectedIds], newId); - } - router.dismissAll(); + await addFolder(name, [...selectedIds]); + } catch (error) { + Alert.alert( + '폴더 생성 실패', + getFolderErrorMessage(error, '폴더를 생성하지 못했습니다. 잠시 후 다시 시도해주세요.'), + ); + setIsCreating(false); + return; + } + + try { + await refreshLinks(); + } catch { + // Link refresh is best-effort after the folder has already been created. } finally { setIsCreating(false); } + + router.dismissAll(); + router.replace({ + pathname: '/(tabs)/(folder)', + params: { folderCreated: String(Date.now()) }, + }); }; const selectedCount = selectedIds.size; - const name = folderName ?? '새 폴더'; return ( <> diff --git a/app/(tabs)/(folder)/index.tsx b/app/(tabs)/(folder)/index.tsx index c8a47c7..c2093bc 100644 --- a/app/(tabs)/(folder)/index.tsx +++ b/app/(tabs)/(folder)/index.tsx @@ -1,5 +1,7 @@ -import { useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { + ActivityIndicator, + Alert, KeyboardAvoidingView, Modal, Platform, @@ -12,15 +14,16 @@ import { View, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { useRouter } from 'expo-router'; +import { useLocalSearchParams, useRouter } from 'expo-router'; import { AddFolderButton } from '@/components/ui/add-folder-button'; import { FolderCard } from '@/components/ui/folder-card'; import { FolderContextMenu } from '@/components/ui/folder-context-menu'; import { SectionHeader } from '@/components/ui/section-header'; +import { Toast } from '@/components/ui/toast'; import { Colors, Typography } from '@/constants/theme'; import type { AnchorPosition } from '@/components/ui/folder-card'; import { useSavedLinks } from '@/context/saved-links-context'; -import { useFolders } from '@/context/folders-context'; +import { getFolderErrorMessage, useFolders } from '@/context/folders-context'; type MenuState = { visible: boolean; @@ -30,59 +33,123 @@ type MenuState = { export default function FolderScreen() { const router = useRouter(); - const { links, assignCategory } = useSavedLinks(); - const { folders: rawFolders, renameFolder, deleteFolder } = useFolders(); + const { folderCreated: folderCreatedParam } = useLocalSearchParams<{ + folderCreated?: string | string[]; + }>(); + const { refreshLinks } = useSavedLinks(); + const { + folders: rawFolders, + isLoading, + errorMessage, + renameFolder, + deleteFolder, + } = useFolders(); const [menuState, setMenuState] = useState({ visible: false }); - const [renameState, setRenameState] = useState<{ visible: boolean; folderId?: number; value: string }>({ + const [renameState, setRenameState] = useState<{ + visible: boolean; + folderId?: number; + value: string; + currentName: string; + }>({ visible: false, value: '', + currentName: '', }); + const [isMutating, setIsMutating] = useState(false); + const [createToastVisible, setCreateToastVisible] = useState(false); + const [deleteToastVisible, setDeleteToastVisible] = useState(false); + const [renameToastVisible, setRenameToastVisible] = useState(false); + const lastCreatedToastRef = useRef(undefined); const renameInputRef = useRef(null); + const folderCreated = typeof folderCreatedParam === 'string' ? folderCreatedParam : undefined; const folders = useMemo( - () => rawFolders.map((f) => ({ - ...f, - linkCount: links.filter((l) => l.categoryId === f.id).length, - })), - [links, rawFolders] + () => rawFolders.map((folder) => ({ ...folder })), + [rawFolders], ); const handleMorePress = (folderId: number, anchor: AnchorPosition) => { setMenuState({ visible: true, anchor, folderId }); }; + useEffect(() => { + if (!folderCreated || lastCreatedToastRef.current === folderCreated) { + return; + } + + lastCreatedToastRef.current = folderCreated; + setCreateToastVisible(true); + }, [folderCreated]); + const handleEditName = () => { if (menuState.folderId == null) return; const current = folders.find((f) => f.id === menuState.folderId)?.name ?? ''; - setRenameState({ visible: true, folderId: menuState.folderId, value: current }); + setRenameState({ visible: true, folderId: menuState.folderId, value: current, currentName: current }); setTimeout(() => renameInputRef.current?.focus(), 100); }; - const handleRenameConfirm = () => { + const handleRenameConfirm = async () => { const trimmed = renameState.value.trim(); - if (renameState.folderId == null || !trimmed) return; - renameFolder(renameState.folderId, trimmed); - setRenameState({ visible: false, value: '' }); + const currentName = renameState.currentName.trim(); + if (renameState.folderId == null || !trimmed || trimmed === currentName || isMutating) return; + setIsMutating(true); + try { + await renameFolder(renameState.folderId, trimmed); + setRenameState({ visible: false, value: '', currentName: '' }); + setRenameToastVisible(true); + } catch (error) { + Alert.alert( + '폴더명 수정 실패', + getFolderErrorMessage(error, '폴더 이름을 수정하지 못했습니다. 잠시 후 다시 시도해주세요.'), + ); + } finally { + setIsMutating(false); + } }; const handleRenameCancel = () => { - setRenameState({ visible: false, value: '' }); + setRenameState({ visible: false, value: '', currentName: '' }); }; const handleDelete = () => { if (menuState.folderId == null) return; const folderId = menuState.folderId; - const linkIds = links.filter((link) => link.categoryId === folderId).map((link) => link.id); - if (linkIds.length > 0) { - assignCategory(linkIds, null); - } - deleteFolder(folderId); + + Alert.alert('폴더 삭제', '폴더를 삭제할까요? 폴더 안의 링크는 미분류 상태로 이동합니다.', [ + { text: '취소', style: 'cancel' }, + { + text: '삭제', + style: 'destructive', + onPress: async () => { + if (isMutating) return; + setIsMutating(true); + try { + await deleteFolder(folderId); + await refreshLinks(); + setDeleteToastVisible(true); + } catch (error) { + Alert.alert( + '폴더 삭제 실패', + getFolderErrorMessage(error, '폴더를 삭제하지 못했습니다. 잠시 후 다시 시도해주세요.'), + ); + } finally { + setIsMutating(false); + } + }, + }, + ]); }; const handleAddFolder = () => { router.push('/folder-name'); }; + const trimmedRenameValue = renameState.value.trim(); + const renameSubmitDisabled = + trimmedRenameValue.length === 0 || + trimmedRenameValue === renameState.currentName.trim() || + isMutating; + return ( } /> - {folders.length > 0 ? ( + {errorMessage ? ( + + {errorMessage} + + ) : null} + + {isLoading ? ( + + + + ) : folders.length > 0 ? ( {folders.map((folder) => ( setMenuState({ visible: false })} /> + setDeleteToastVisible(false)} + /> + setCreateToastVisible(false)} + /> + setRenameToastVisible(false)} + /> + {/* 폴더명 수정 모달 */} 취소 - + 저장 @@ -232,6 +331,22 @@ const styles = StyleSheet.create({ flexWrap: 'wrap', gap: 12, }, + errorBox: { + borderRadius: 12, + borderWidth: 1, + borderColor: Colors.brand.textWarning, + backgroundColor: Colors.brand.surface, + paddingHorizontal: 14, + paddingVertical: 10, + }, + errorText: { + ...Typography.caption, + color: Colors.brand.textWarning, + }, + loadingState: { + paddingVertical: 40, + alignItems: 'center', + }, emptyState: { paddingVertical: 40, diff --git a/app/(tabs)/(home)/index.tsx b/app/(tabs)/(home)/index.tsx index 29b792d..cec21b0 100644 --- a/app/(tabs)/(home)/index.tsx +++ b/app/(tabs)/(home)/index.tsx @@ -16,6 +16,7 @@ import { SectionHeader } from '@/components/ui/section-header'; import { TitleEditModal } from '@/components/ui/title-edit-modal'; import { Toast } from '@/components/ui/toast'; import { Colors, Typography } from '@/constants/theme'; +import { useFolders } from '@/context/folders-context'; import { getSavedLinkErrorMessage, useSavedLinks, type SavedLink } from '@/context/saved-links-context'; import type { AnchorPosition } from '@/components/ui/folder-card'; @@ -24,6 +25,7 @@ export default function HomeScreen() { savedLinkToast?: string | string[]; }>(); const { links, toggleBookmark, deleteLink, updateTitle } = useSavedLinks(); + const { refreshFolders } = useFolders(); const [menuState, setMenuState] = useState<{ visible: boolean; anchor?: AnchorPosition; linkId?: number }>({ visible: false }); const [editingLink, setEditingLink] = useState(null); const [saveToastVisible, setSaveToastVisible] = useState(false); @@ -74,6 +76,7 @@ export default function HomeScreen() { onPress: async () => { try { await deleteLink(id); + await refreshFolders(); setDeleteToastVisible(true); } catch (error) { Alert.alert( @@ -85,7 +88,7 @@ export default function HomeScreen() { }, ]); }, - [deleteLink], + [deleteLink, refreshFolders], ); const openTitleModal = useCallback((link: SavedLink) => { diff --git a/app/(tabs)/(home)/saved-links.tsx b/app/(tabs)/(home)/saved-links.tsx index c68fedb..46670ac 100644 --- a/app/(tabs)/(home)/saved-links.tsx +++ b/app/(tabs)/(home)/saved-links.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useCallback } from 'react'; +import { useState, useMemo, useCallback, useEffect } from 'react'; import { ActivityIndicator, Alert, @@ -19,19 +19,9 @@ import { TitleEditModal } from '@/components/ui/title-edit-modal'; import { Toast } from '@/components/ui/toast'; import type { AnchorPosition } from '@/components/ui/folder-card'; import { Colors, Typography } from '@/constants/theme'; +import { useFolders } from '@/context/folders-context'; import { getSavedLinkErrorMessage, useSavedLinks, type SavedLink } from '@/context/saved-links-context'; -// ─── 폴더 필터 칩 아이템 ────────────────────────────────────────────────────── -// 실제 구현 시 GET /api/v1/categories 응답으로 교체 - -// 폴더명은 folder/index.tsx MOCK_FOLDERS와 동일하게 유지 (API 연동 시 GET /categories로 교체) -const FOLDER_ITEMS: FilterChipItem[] = [ - { label: '전체', value: 'all' }, - { label: '맛집 정보', value: '1' }, - { label: '취업', value: '2' }, - { label: '취미', value: '3' }, -]; - // ─── Screen ─────────────────────────────────────────────────────────────────── export default function SavedLinksScreen() { @@ -47,6 +37,11 @@ export default function SavedLinksScreen() { deleteLink, updateTitle, } = useSavedLinks(); + const { + folders, + errorMessage: folderErrorMessage, + refreshFolders, + } = useFolders(); const [selectedFolder, setSelectedFolder] = useState('all'); const [bookmarkFilter, setBookmarkFilter] = useState(false); @@ -58,6 +53,14 @@ export default function SavedLinksScreen() { const [deleteToastVisible, setDeleteToastVisible] = useState(false); const [titleToastVisible, setTitleToastVisible] = useState(false); + const folderItems = useMemo( + () => [ + { label: '전체', value: 'all' }, + ...folders.map((folder) => ({ label: folder.name, value: String(folder.id) })), + ], + [folders], + ); + const displayLinks = useMemo(() => { const folderFiltered = selectedFolder === 'all' @@ -71,6 +74,16 @@ export default function SavedLinksScreen() { return folderFiltered.filter((link) => link.isBookmarked); }, [links, selectedFolder, bookmarkFilter]); + useEffect(() => { + if (selectedFolder === 'all') { + return; + } + + if (!folders.some((folder) => String(folder.id) === selectedFolder)) { + setSelectedFolder('all'); + } + }, [folders, selectedFolder]); + const openTitleModal = useCallback((link: SavedLink) => { setEditingLink(link); }, []); @@ -99,6 +112,7 @@ export default function SavedLinksScreen() { onPress: async () => { try { await deleteLink(id); + await refreshFolders(); setDeleteToastVisible(true); } catch (error) { Alert.alert( @@ -110,7 +124,7 @@ export default function SavedLinksScreen() { }, ]); }, - [deleteLink], + [deleteLink, refreshFolders], ); const openMoreMenu = useCallback( @@ -151,7 +165,7 @@ export default function SavedLinksScreen() { @@ -166,6 +180,11 @@ export default function SavedLinksScreen() { {errorMessage} ) : null} + {folderErrorMessage ? ( + + {folderErrorMessage} + + ) : null} {/* 링크 목록 */} { - void refreshLinks(); + void Promise.all([refreshLinks(), refreshFolders()]); }} tintColor={Colors.brand.primary} /> diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 2005dfa..299b867 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from 'react'; import { ShareIntentRouter } from '@/components/share-intent-router'; import { BottomTabBar, type TabVariant } from '@/components/ui/bottom-tab-bar'; +import { FoldersProvider } from '@/context/folders-context'; import { SavedLinksProvider } from '@/context/saved-links-context'; import { syncAuthenticatedMember } from '@/services/auth-api'; @@ -121,15 +122,17 @@ export default function TabLayout() { return ( - - } - screenOptions={{ headerShown: false }} - > - - - - + + + } + screenOptions={{ headerShown: false }} + > + + + + + ); } diff --git a/context/folders-context.tsx b/context/folders-context.tsx index 2eb72c4..00bb7d5 100644 --- a/context/folders-context.tsx +++ b/context/folders-context.tsx @@ -1,49 +1,138 @@ -import { createContext, useCallback, useContext, useState } from 'react'; +import { useAuth } from '@clerk/expo'; +import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'; -export interface Folder { - id: number; - name: string; -} +import { + createCategory, + deleteCategory, + fetchCategories, + renameCategory, + type CategoryCreateRequest, + type CategoryResponse, +} from '@/api/categories'; + +const LOGIN_REQUIRED_MESSAGE = + '로그인 상태를 확인할 수 없습니다. 다시 로그인한 뒤 시도해주세요.'; + +export type Folder = CategoryResponse; interface FoldersContextValue { folders: Folder[]; - addFolder: (name: string) => number; - renameFolder: (id: number, name: string) => void; - deleteFolder: (id: number) => void; + isLoading: boolean; + errorMessage: string; + refreshFolders: () => Promise; + addFolder: (name: string, linkIds?: number[]) => Promise; + renameFolder: (id: number, name: string) => Promise; + deleteFolder: (id: number) => Promise; } -const MOCK_FOLDERS: Folder[] = [ - { id: 1, name: '맛집 정보' }, - { id: 2, name: '취업' }, - { id: 3, name: '취미' }, -]; - -let nextId = MOCK_FOLDERS.length + 1; - const FoldersContext = createContext(null); export function FoldersProvider({ children }: { children: React.ReactNode }) { - const [folders, setFolders] = useState(MOCK_FOLDERS); + const { getToken, isLoaded, isSignedIn } = useAuth(); + const [folders, setFolders] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + const getTokenRef = useRef(getToken); - const addFolder = useCallback((name: string): number => { - // TODO: 백엔드 연동 시 POST /api/v1/categories { name } 호출 후 응답 id로 교체 - const id = nextId++; - setFolders((prev) => [...prev, { id, name }]); - return id; - }, []); + useEffect(() => { + getTokenRef.current = getToken; + }, [getToken]); - const renameFolder = useCallback((id: number, name: string) => { - // TODO: PATCH /api/v1/categories/{id} - setFolders((prev) => prev.map((f) => (f.id === id ? { ...f, name } : f))); - }, []); + const refreshFolders = useCallback(async () => { + if (!isLoaded) { + return; + } + + if (!isSignedIn) { + setFolders([]); + setErrorMessage(LOGIN_REQUIRED_MESSAGE); + return; + } + + setIsLoading(true); + setErrorMessage(''); + + try { + const response = await fetchCategories(() => getTokenRef.current()); + setFolders(sortFolders(response.items)); + } catch (error) { + setErrorMessage(getFolderErrorMessage(error, '폴더 목록을 불러오지 못했습니다.')); + } finally { + setIsLoading(false); + } + }, [isLoaded, isSignedIn]); + + useEffect(() => { + if (!isLoaded) { + return; + } + + if (!isSignedIn) { + setFolders([]); + setErrorMessage(''); + return; + } + + void refreshFolders(); + }, [isLoaded, isSignedIn, refreshFolders]); - const deleteFolder = useCallback((id: number) => { - // TODO: DELETE /api/v1/categories/{id} - setFolders((prev) => prev.filter((f) => f.id !== id)); + const addFolder = useCallback(async (name: string, linkIds: number[] = []) => { + const request: CategoryCreateRequest = { + name, + ...(linkIds.length > 0 ? { linkIds } : {}), + }; + + const folder = await createCategory(() => getTokenRef.current(), request); + setFolders((prev) => sortFolders([...prev.filter((item) => item.id !== folder.id), folder])); + setErrorMessage(''); + return folder; }, []); + const renameFolder = useCallback(async (id: number, name: string) => { + const previousFolders = folders; + setFolders((prev) => + prev.map((folder) => (folder.id === id ? { ...folder, name } : folder)), + ); + + try { + const response = await renameCategory(() => getTokenRef.current(), id, name); + setFolders((prev) => + prev.map((folder) => + folder.id === response.id ? { ...folder, name: response.name } : folder, + ), + ); + setErrorMessage(''); + } catch (error) { + setFolders(previousFolders); + throw error; + } + }, [folders]); + + const deleteFolder = useCallback(async (id: number) => { + const previousFolders = folders; + setFolders((prev) => prev.filter((folder) => folder.id !== id)); + + try { + await deleteCategory(() => getTokenRef.current(), id); + setErrorMessage(''); + } catch (error) { + setFolders(previousFolders); + throw error; + } + }, [folders]); + return ( - + {children} ); @@ -54,3 +143,27 @@ export function useFolders(): FoldersContextValue { if (!ctx) throw new Error('useFolders must be used within FoldersProvider'); return ctx; } + +export function getFolderErrorMessage(error: unknown, fallbackMessage: string) { + if (error instanceof Error) { + if (error.message === 'Missing Clerk session token') { + return LOGIN_REQUIRED_MESSAGE; + } + + if (error.message) { + return error.message; + } + } + + return fallbackMessage; +} + +function sortFolders(folders: Folder[]) { + return [...folders].sort((a, b) => { + if (a.displayOrder !== b.displayOrder) { + return a.displayOrder - b.displayOrder; + } + + return a.id - b.id; + }); +}