From 57a0c52f159b381d7710468aaa829361d1942895 Mon Sep 17 00:00:00 2001 From: KimByeongHun Date: Sat, 23 May 2026 16:59:47 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=ED=8F=B4=EB=8D=94=20API=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/categories.ts | 71 +++++++++ app/(tabs)/(folder)/[id].tsx | 24 +-- app/(tabs)/(folder)/_layout.tsx | 11 +- app/(tabs)/(folder)/folder-add-url.tsx | 12 +- app/(tabs)/(folder)/folder-name.tsx | 32 +++- app/(tabs)/(folder)/folder-url-select.tsx | 19 ++- app/(tabs)/(folder)/index.tsx | 98 +++++++++--- app/(tabs)/(home)/index.tsx | 5 +- app/(tabs)/(home)/saved-links.tsx | 49 ++++-- app/(tabs)/_layout.tsx | 21 +-- context/folders-context.tsx | 175 ++++++++++++++++++---- 11 files changed, 411 insertions(+), 106 deletions(-) create mode 100644 api/categories.ts 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..9ac278c 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'; @@ -8,7 +8,7 @@ import { CardLink } from '@/components/ui/card-link'; import { FolderContextMenu } from '@/components/ui/folder-context-menu'; import { Toast } from '@/components/ui/toast'; import { Colors, Typography } from '@/constants/theme'; -import { useSavedLinks } from '@/context/saved-links-context'; +import { getSavedLinkErrorMessage, useSavedLinks } from '@/context/saved-links-context'; import { useFolders } from '@/context/folders-context'; import type { AnchorPosition } from '@/components/ui/folder-card'; import { useEffect, useState } from 'react'; @@ -25,7 +25,7 @@ export default function FolderDetailScreen() { const folderId = Number(id); const { links, toggleBookmark, assignCategory } = useSavedLinks(); - const { folders } = useFolders(); + const { folders, refreshFolders } = useFolders(); const folderLinks = links.filter((l) => l.categoryId === folderId); const folderName = folders.find((f) => f.id === folderId)?.name ?? '폴더'; @@ -41,10 +41,17 @@ 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 handleAddUrl = () => { @@ -134,8 +141,7 @@ export default function FolderDetailScreen() { destructive: true, onPress: () => { if (menuState.linkId == null) return; - // API: PATCH /api/v1/saved-links/{id} { categoryId: null } - handleDelete(menuState.linkId); + void handleDelete(menuState.linkId); }, }, ]} 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..1d67eae 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,20 @@ 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); - } + await addFolder(name, [...selectedIds]); + await refreshLinks(); router.dismissAll(); + } catch (error) { + Alert.alert( + '폴더 생성 실패', + getFolderErrorMessage(error, '폴더를 생성하지 못했습니다. 잠시 후 다시 시도해주세요.'), + ); } finally { setIsCreating(false); } }; const selectedCount = selectedIds.size; - const name = folderName ?? '새 폴더'; return ( <> diff --git a/app/(tabs)/(folder)/index.tsx b/app/(tabs)/(folder)/index.tsx index c8a47c7..b8a3f4f 100644 --- a/app/(tabs)/(folder)/index.tsx +++ b/app/(tabs)/(folder)/index.tsx @@ -1,5 +1,7 @@ import { useMemo, useRef, useState } from 'react'; import { + ActivityIndicator, + Alert, KeyboardAvoidingView, Modal, Platform, @@ -20,7 +22,7 @@ import { SectionHeader } from '@/components/ui/section-header'; 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,21 +32,25 @@ type MenuState = { export default function FolderScreen() { const router = useRouter(); - const { links, assignCategory } = useSavedLinks(); - const { folders: rawFolders, renameFolder, deleteFolder } = useFolders(); + 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 }>({ visible: false, value: '', }); + const [isMutating, setIsMutating] = useState(false); const renameInputRef = useRef(null); 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) => { @@ -58,11 +64,21 @@ export default function FolderScreen() { 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: '' }); + if (renameState.folderId == null || !trimmed || isMutating) return; + setIsMutating(true); + try { + await renameFolder(renameState.folderId, trimmed); + setRenameState({ visible: false, value: '' }); + } catch (error) { + Alert.alert( + '폴더명 수정 실패', + getFolderErrorMessage(error, '폴더 이름을 수정하지 못했습니다. 잠시 후 다시 시도해주세요.'), + ); + } finally { + setIsMutating(false); + } }; const handleRenameCancel = () => { @@ -72,11 +88,29 @@ export default function FolderScreen() { 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(); + } catch (error) { + Alert.alert( + '폴더 삭제 실패', + getFolderErrorMessage(error, '폴더를 삭제하지 못했습니다. 잠시 후 다시 시도해주세요.'), + ); + } finally { + setIsMutating(false); + } + }, + }, + ]); }; const handleAddFolder = () => { @@ -103,7 +137,17 @@ export default function FolderScreen() { rightSlot={} /> - {folders.length > 0 ? ( + {errorMessage ? ( + + {errorMessage} + + ) : null} + + {isLoading ? ( + + + + ) : folders.length > 0 ? ( {folders.map((folder) => ( 저장 @@ -232,6 +276,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; + }); +} From 4fd9098c090d212f8481a63b2f844fec529b635e Mon Sep 17 00:00:00 2001 From: KimByeongHun Date: Sat, 23 May 2026 17:19:46 +0900 Subject: [PATCH 2/7] =?UTF-8?q?fix:=20=ED=8F=B4=EB=8D=94=20=EB=A7=81?= =?UTF-8?q?=ED=81=AC=20=EC=A0=9C=EC=99=B8=20=ED=99=95=EC=9D=B8=EC=B0=BD=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/(folder)/[id].tsx | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/app/(tabs)/(folder)/[id].tsx b/app/(tabs)/(folder)/[id].tsx index 9ac278c..5a8b8bd 100644 --- a/app/(tabs)/(folder)/[id].tsx +++ b/app/(tabs)/(folder)/[id].tsx @@ -54,6 +54,23 @@ export default function FolderDetailScreen() { } }; + const confirmDeleteFromFolder = (linkId: number) => { + Alert.alert( + '폴더에서 삭제할까요?', + '링크는 삭제되지 않아요.\n현재 폴더에서만 제외돼요.', + [ + { text: '취소', style: 'cancel' }, + { + text: '폴더에서 삭제', + style: 'destructive', + onPress: () => { + void handleDelete(linkId); + }, + }, + ], + ); + }; + const handleAddUrl = () => { router.push({ pathname: '/(tabs)/(folder)/folder-add-url', @@ -141,7 +158,7 @@ export default function FolderDetailScreen() { destructive: true, onPress: () => { if (menuState.linkId == null) return; - void handleDelete(menuState.linkId); + confirmDeleteFromFolder(menuState.linkId); }, }, ]} From af62a1c4cb7ddcae3ed48aeea86692cc4b0a3361 Mon Sep 17 00:00:00 2001 From: KimByeongHun Date: Sat, 23 May 2026 17:31:25 +0900 Subject: [PATCH 3/7] =?UTF-8?q?fix:=20=ED=8F=B4=EB=8D=94=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EC=82=AD=EC=A0=9C=20=ED=86=A0=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/(folder)/folder-url-select.tsx | 4 +++ app/(tabs)/(folder)/index.tsx | 37 +++++++++++++++++++++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/app/(tabs)/(folder)/folder-url-select.tsx b/app/(tabs)/(folder)/folder-url-select.tsx index 1d67eae..f1ab321 100644 --- a/app/(tabs)/(folder)/folder-url-select.tsx +++ b/app/(tabs)/(folder)/folder-url-select.tsx @@ -76,6 +76,10 @@ export default function FolderUrlSelectScreen() { await addFolder(name, [...selectedIds]); await refreshLinks(); router.dismissAll(); + router.replace({ + pathname: '/(tabs)/(folder)', + params: { folderCreated: String(Date.now()) }, + }); } catch (error) { Alert.alert( '폴더 생성 실패', diff --git a/app/(tabs)/(folder)/index.tsx b/app/(tabs)/(folder)/index.tsx index b8a3f4f..6bd76d1 100644 --- a/app/(tabs)/(folder)/index.tsx +++ b/app/(tabs)/(folder)/index.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, Alert, @@ -14,11 +14,12 @@ 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'; @@ -32,6 +33,9 @@ type MenuState = { export default function FolderScreen() { const router = useRouter(); + const { folderCreated: folderCreatedParam } = useLocalSearchParams<{ + folderCreated?: string | string[]; + }>(); const { refreshLinks } = useSavedLinks(); const { folders: rawFolders, @@ -46,7 +50,11 @@ export default function FolderScreen() { value: '', }); const [isMutating, setIsMutating] = useState(false); + const [createToastVisible, setCreateToastVisible] = useState(false); + const [deleteToastVisible, setDeleteToastVisible] = useState(false); + const lastCreatedToastRef = useRef(undefined); const renameInputRef = useRef(null); + const folderCreated = typeof folderCreatedParam === 'string' ? folderCreatedParam : undefined; const folders = useMemo( () => rawFolders.map((folder) => ({ ...folder })), @@ -57,6 +65,15 @@ export default function FolderScreen() { 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 ?? ''; @@ -100,6 +117,7 @@ export default function FolderScreen() { try { await deleteFolder(folderId); await refreshLinks(); + setDeleteToastVisible(true); } catch (error) { Alert.alert( '폴더 삭제 실패', @@ -178,6 +196,21 @@ export default function FolderScreen() { onDismiss={() => setMenuState({ visible: false })} /> + setDeleteToastVisible(false)} + /> + setCreateToastVisible(false)} + /> + {/* 폴더명 수정 모달 */} Date: Sat, 23 May 2026 17:47:35 +0900 Subject: [PATCH 4/7] =?UTF-8?q?fix:=20=ED=8F=B4=EB=8D=94=20=EB=A7=81?= =?UTF-8?q?=ED=81=AC=20=EB=A9=94=EB=89=B4=20=EC=A0=9C=EB=AA=A9=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/(folder)/[id].tsx | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/app/(tabs)/(folder)/[id].tsx b/app/(tabs)/(folder)/[id].tsx index 5a8b8bd..b920ebf 100644 --- a/app/(tabs)/(folder)/[id].tsx +++ b/app/(tabs)/(folder)/[id].tsx @@ -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 { getSavedLinkErrorMessage, 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 { 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); @@ -80,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 ( {/* 헤더 */} @@ -146,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); }, }, { @@ -164,6 +171,20 @@ export default function FolderDetailScreen() { ]} onDismiss={() => setMenuState({ visible: false })} /> + + setEditingLink(null)} + /> + + setTitleToastVisible(false)} + /> ); } From 514a64df48dbc33e368fc9b922af304c082e1bcd Mon Sep 17 00:00:00 2001 From: KimByeongHun Date: Sat, 23 May 2026 17:53:17 +0900 Subject: [PATCH 5/7] =?UTF-8?q?fix:=20=ED=8F=B4=EB=8D=94=EB=AA=85=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=ED=86=A0=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/(folder)/index.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/(tabs)/(folder)/index.tsx b/app/(tabs)/(folder)/index.tsx index 6bd76d1..4801a17 100644 --- a/app/(tabs)/(folder)/index.tsx +++ b/app/(tabs)/(folder)/index.tsx @@ -52,6 +52,7 @@ export default function FolderScreen() { 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; @@ -88,6 +89,7 @@ export default function FolderScreen() { try { await renameFolder(renameState.folderId, trimmed); setRenameState({ visible: false, value: '' }); + setRenameToastVisible(true); } catch (error) { Alert.alert( '폴더명 수정 실패', @@ -210,6 +212,13 @@ export default function FolderScreen() { topOffset={96} onHide={() => setCreateToastVisible(false)} /> + setRenameToastVisible(false)} + /> {/* 폴더명 수정 모달 */} Date: Sat, 23 May 2026 18:15:22 +0900 Subject: [PATCH 6/7] =?UTF-8?q?fix:=20=ED=8F=B4=EB=8D=94=EB=AA=85=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=B2=84=ED=8A=BC=20=EB=B9=84=ED=99=9C?= =?UTF-8?q?=EC=84=B1=ED=99=94=20=EC=A1=B0=EA=B1=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/(folder)/index.tsx | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/app/(tabs)/(folder)/index.tsx b/app/(tabs)/(folder)/index.tsx index 4801a17..c2093bc 100644 --- a/app/(tabs)/(folder)/index.tsx +++ b/app/(tabs)/(folder)/index.tsx @@ -45,9 +45,15 @@ export default function FolderScreen() { 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); @@ -78,17 +84,18 @@ export default function FolderScreen() { 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 = async () => { const trimmed = renameState.value.trim(); - if (renameState.folderId == null || !trimmed || isMutating) return; + 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: '' }); + setRenameState({ visible: false, value: '', currentName: '' }); setRenameToastVisible(true); } catch (error) { Alert.alert( @@ -101,7 +108,7 @@ export default function FolderScreen() { }; const handleRenameCancel = () => { - setRenameState({ visible: false, value: '' }); + setRenameState({ visible: false, value: '', currentName: '' }); }; const handleDelete = () => { @@ -137,6 +144,12 @@ export default function FolderScreen() { router.push('/folder-name'); }; + const trimmedRenameValue = renameState.value.trim(); + const renameSubmitDisabled = + trimmedRenameValue.length === 0 || + trimmedRenameValue === renameState.currentName.trim() || + isMutating; + return ( 취소 - + 저장 From 16ab57a5418043715e78d70da3710fda1901d276 Mon Sep 17 00:00:00 2001 From: KimByeongHun Date: Sun, 24 May 2026 02:40:01 +0900 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20=ED=8F=B4=EB=8D=94=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=ED=9B=84=20=EB=A7=81=ED=81=AC=20=EA=B0=B1=EC=8B=A0?= =?UTF-8?q?=20=EC=8B=A4=ED=8C=A8=20=EC=B2=98=EB=A6=AC=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/(folder)/folder-url-select.tsx | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/app/(tabs)/(folder)/folder-url-select.tsx b/app/(tabs)/(folder)/folder-url-select.tsx index f1ab321..5dd64b7 100644 --- a/app/(tabs)/(folder)/folder-url-select.tsx +++ b/app/(tabs)/(folder)/folder-url-select.tsx @@ -74,20 +74,28 @@ export default function FolderUrlSelectScreen() { setIsCreating(true); try { await addFolder(name, [...selectedIds]); - await refreshLinks(); - router.dismissAll(); - router.replace({ - pathname: '/(tabs)/(folder)', - params: { folderCreated: String(Date.now()) }, - }); } 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;