diff --git a/api/saved-links.ts b/api/saved-links.ts new file mode 100644 index 0000000..91425fd --- /dev/null +++ b/api/saved-links.ts @@ -0,0 +1,146 @@ +import { + authenticatedApiRequest, + type ApiRequestOptions, + type ClerkTokenGetter, +} from '@/api/api-client'; + +export type SavedLinkVerdict = 'safe' | 'caution' | 'danger'; + +export type SavedLinkResponse = { + id: number; + analysisId: string; + categoryId: number | null; + originalUrl: string; + finalUrl: string | null; + title: string; + description?: string | null; + verdict: SavedLinkVerdict; + isBookmarked: boolean; + createdAt: string; +}; + +export type SavedLinkListResponse = { + items: SavedLinkResponse[]; + hasNext: boolean; + nextCursor: string | null; +}; + +export type SavedLinkCreateRequest = { + analysisId: string; + categoryId: number | null; + title: string; + description?: string | null; +}; + +export type SavedLinkListQuery = { + categoryId?: number | null; + bookmarked?: boolean; + cursor?: string | null; + size?: number; +}; + +export type BookmarkToggleResponse = { + id: number; + isBookmarked: boolean; +}; + +export type CategoryUpdateResponse = { + id: number; + categoryId: number | null; +}; + +export type SavedLinkTitleUpdateResponse = { + id: number; + title: string; +}; + +type SavedLinkRequestOptions = Pick; + +export function createSavedLink( + getToken: ClerkTokenGetter, + body: SavedLinkCreateRequest, + options: SavedLinkRequestOptions = {}, +) { + return authenticatedApiRequest(getToken, '/api/v1/saved-links', { + ...options, + method: 'POST', + body, + }); +} + +export function fetchSavedLinks( + getToken: ClerkTokenGetter, + query: SavedLinkListQuery = {}, + options: SavedLinkRequestOptions = {}, +) { + return authenticatedApiRequest( + getToken, + `/api/v1/saved-links${buildSavedLinkQuery(query)}`, + options, + ); +} + +export function deleteSavedLink(getToken: ClerkTokenGetter, id: number) { + return authenticatedApiRequest( + getToken, + `/api/v1/saved-links/${encodeURIComponent(String(id))}`, + { method: 'DELETE' }, + ); +} + +export function toggleSavedLinkBookmark(getToken: ClerkTokenGetter, id: number) { + return authenticatedApiRequest( + getToken, + `/api/v1/saved-links/${encodeURIComponent(String(id))}/bookmark`, + { method: 'PATCH' }, + ); +} + +export function updateSavedLinkCategory( + getToken: ClerkTokenGetter, + id: number, + categoryId: number | null, +) { + return authenticatedApiRequest( + getToken, + `/api/v1/saved-links/${encodeURIComponent(String(id))}/category`, + { + method: 'PATCH', + body: { categoryId }, + }, + ); +} + +export function updateSavedLinkTitle(getToken: ClerkTokenGetter, id: number, title: string) { + return authenticatedApiRequest( + getToken, + `/api/v1/saved-links/${encodeURIComponent(String(id))}/title`, + { + method: 'PATCH', + body: { title }, + }, + ); +} + +function buildSavedLinkQuery(query: SavedLinkListQuery) { + const params = new URLSearchParams(); + + if (query.categoryId != null) { + params.set('categoryId', String(query.categoryId)); + } + + if (query.bookmarked != null) { + params.set('bookmarked', String(query.bookmarked)); + } + + if (query.cursor != null) { + params.set('cursor', query.cursor); + } + + if (query.size != null) { + params.set('size', String(query.size)); + } + + const queryString = params.toString(); + return queryString ? `?${queryString}` : ''; +} diff --git a/app/(tabs)/(home)/index.tsx b/app/(tabs)/(home)/index.tsx index 1e74d9d..29b792d 100644 --- a/app/(tabs)/(home)/index.tsx +++ b/app/(tabs)/(home)/index.tsx @@ -1,29 +1,102 @@ -import { useState } from 'react'; -import { ScrollView, StyleSheet, Text, View } from 'react-native'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + Alert, + ScrollView, + StyleSheet, + Text, + View, +} from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { router } from 'expo-router'; +import { router, useLocalSearchParams } from 'expo-router'; import { AppIcon } from '@/components/ui/app-icon'; import { CardLink } from '@/components/ui/card-link'; import { FolderContextMenu } from '@/components/ui/folder-context-menu'; import { IconSymbol } from '@/components/ui/icon-symbol'; 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 { useSavedLinks } from '@/context/saved-links-context'; +import { getSavedLinkErrorMessage, useSavedLinks, type SavedLink } from '@/context/saved-links-context'; import type { AnchorPosition } from '@/components/ui/folder-card'; export default function HomeScreen() { - const { links, toggleBookmark, deleteLink } = useSavedLinks(); + const { savedLinkToast: savedLinkToastParam } = useLocalSearchParams<{ + savedLinkToast?: string | string[]; + }>(); + const { links, toggleBookmark, deleteLink, updateTitle } = useSavedLinks(); const [menuState, setMenuState] = useState<{ visible: boolean; anchor?: AnchorPosition; linkId?: number }>({ visible: false }); + const [editingLink, setEditingLink] = useState(null); + const [saveToastVisible, setSaveToastVisible] = useState(false); + const [deleteToastVisible, setDeleteToastVisible] = useState(false); + const [titleToastVisible, setTitleToastVisible] = useState(false); + const lastToastParamRef = useRef(undefined); + const savedLinkToast = typeof savedLinkToastParam === 'string' ? savedLinkToastParam : undefined; // 최근 저장한 링크 — createdAt 내림차순 상위 3개 const recentLinks = links.slice(0, 3); + useEffect(() => { + if (!savedLinkToast || lastToastParamRef.current === savedLinkToast) { + return; + } + + lastToastParamRef.current = savedLinkToast; + setSaveToastVisible(true); + }, [savedLinkToast]); + const handleMore = (id: number, anchor: AnchorPosition) => { setMenuState({ visible: true, anchor, linkId: id }); }; const selectedLink = links.find((l) => l.id === menuState.linkId); + const handleBookmark = useCallback( + async (id: number) => { + try { + await toggleBookmark(id); + } catch (error) { + Alert.alert( + '북마크 변경 실패', + getSavedLinkErrorMessage(error, '북마크 상태를 변경하지 못했습니다. 잠시 후 다시 시도해주세요.'), + ); + } + }, + [toggleBookmark], + ); + + const handleDelete = useCallback( + (id: number) => { + Alert.alert('링크 삭제', '저장한 링크를 삭제할까요?', [ + { text: '취소', style: 'cancel' }, + { + text: '삭제', + style: 'destructive', + onPress: async () => { + try { + await deleteLink(id); + setDeleteToastVisible(true); + } catch (error) { + Alert.alert( + '삭제 실패', + getSavedLinkErrorMessage(error, '링크를 삭제하지 못했습니다. 잠시 후 다시 시도해주세요.'), + ); + } + }, + }, + ]); + }, + [deleteLink], + ); + + const openTitleModal = useCallback((link: SavedLink) => { + setEditingLink(link); + }, []); + + const handleTitleConfirm = useCallback(async (id: number, title: string) => { + await updateTitle(id, title); + setTitleToastVisible(true); + }, [updateTitle]); + return ( toggleBookmark(link.id)} + onBookmark={() => { + void handleBookmark(link.id); + }} onMore={(anchor) => handleMore(link.id, anchor)} /> ))} @@ -88,17 +163,45 @@ export default function HomeScreen() { anchor={menuState.anchor} items={[ { - label: selectedLink?.isBookmarked ? '북마크 제거' : '북마크 추가', - onPress: () => menuState.linkId != null && toggleBookmark(menuState.linkId), + label: '제목 수정', + onPress: () => selectedLink && openTitleModal(selectedLink), }, { label: '링크 삭제', - onPress: () => menuState.linkId != null && deleteLink(menuState.linkId), + onPress: () => menuState.linkId != null && handleDelete(menuState.linkId), destructive: true, }, ]} onDismiss={() => setMenuState({ visible: false })} /> + + setEditingLink(null)} + /> + + setSaveToastVisible(false)} + /> + setDeleteToastVisible(false)} + /> + setTitleToastVisible(false)} + /> ); } diff --git a/app/(tabs)/(home)/saved-links.tsx b/app/(tabs)/(home)/saved-links.tsx index 6b1cbe1..c68fedb 100644 --- a/app/(tabs)/(home)/saved-links.tsx +++ b/app/(tabs)/(home)/saved-links.tsx @@ -1,5 +1,13 @@ import { useState, useMemo, useCallback } from 'react'; -import { FlatList, StyleSheet, Text, View } from 'react-native'; +import { + ActivityIndicator, + Alert, + FlatList, + RefreshControl, + StyleSheet, + Text, + View, +} from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { router } from 'expo-router'; import { AppIcon } from '@/components/ui/app-icon'; @@ -7,9 +15,11 @@ import { BookmarkChip } from '@/components/ui/bookmark-chip'; import { CardLink } from '@/components/ui/card-link'; import { FilterChip, type FilterChipItem } from '@/components/ui/filter-chip'; import { FolderContextMenu, type ContextMenuItem } from '@/components/ui/folder-context-menu'; +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 { useSavedLinks, type SavedLink } from '@/context/saved-links-context'; +import { getSavedLinkErrorMessage, useSavedLinks, type SavedLink } from '@/context/saved-links-context'; // ─── 폴더 필터 칩 아이템 ────────────────────────────────────────────────────── // 실제 구현 시 GET /api/v1/categories 응답으로 교체 @@ -25,7 +35,18 @@ const FOLDER_ITEMS: FilterChipItem[] = [ // ─── Screen ─────────────────────────────────────────────────────────────────── export default function SavedLinksScreen() { - const { links, toggleBookmark, deleteLink } = useSavedLinks(); + const { + links, + isLoading, + isLoadingMore, + errorMessage, + hasNext, + refreshLinks, + loadMoreLinks, + toggleBookmark, + deleteLink, + updateTitle, + } = useSavedLinks(); const [selectedFolder, setSelectedFolder] = useState('all'); const [bookmarkFilter, setBookmarkFilter] = useState(false); @@ -33,6 +54,9 @@ export default function SavedLinksScreen() { const [menuVisible, setMenuVisible] = useState(false); const [menuAnchor, setMenuAnchor] = useState(); const [menuItems, setMenuItems] = useState([]); + const [editingLink, setEditingLink] = useState(null); + const [deleteToastVisible, setDeleteToastVisible] = useState(false); + const [titleToastVisible, setTitleToastVisible] = useState(false); const displayLinks = useMemo(() => { const folderFiltered = @@ -40,30 +64,81 @@ export default function SavedLinksScreen() { ? links : links.filter((link) => String(link.categoryId) === selectedFolder); - if (!bookmarkFilter) return folderFiltered; + if (!bookmarkFilter) { + return folderFiltered; + } - return [...folderFiltered].sort((a, b) => Number(b.isBookmarked) - Number(a.isBookmarked)); + return folderFiltered.filter((link) => link.isBookmarked); }, [links, selectedFolder, bookmarkFilter]); + const openTitleModal = useCallback((link: SavedLink) => { + setEditingLink(link); + }, []); + + const handleBookmark = useCallback( + async (id: number) => { + try { + await toggleBookmark(id); + } catch (error) { + Alert.alert( + '북마크 변경 실패', + getSavedLinkErrorMessage(error, '북마크 상태를 변경하지 못했습니다. 잠시 후 다시 시도해주세요.'), + ); + } + }, + [toggleBookmark], + ); + + const handleDelete = useCallback( + (id: number) => { + Alert.alert('링크 삭제', '저장한 링크를 삭제할까요?', [ + { text: '취소', style: 'cancel' }, + { + text: '삭제', + style: 'destructive', + onPress: async () => { + try { + await deleteLink(id); + setDeleteToastVisible(true); + } catch (error) { + Alert.alert( + '삭제 실패', + getSavedLinkErrorMessage(error, '링크를 삭제하지 못했습니다. 잠시 후 다시 시도해주세요.'), + ); + } + }, + }, + ]); + }, + [deleteLink], + ); + const openMoreMenu = useCallback( (link: SavedLink, anchor: AnchorPosition) => { setMenuAnchor(anchor); setMenuItems([ { - label: link.isBookmarked ? '북마크 제거' : '북마크 추가', - onPress: () => toggleBookmark(link.id), + label: '제목 수정', + onPress: () => openTitleModal(link), }, { label: '링크 삭제', - onPress: () => deleteLink(link.id), + onPress: () => handleDelete(link.id), destructive: true, }, ]); setMenuVisible(true); }, - [toggleBookmark, deleteLink] + [handleDelete, openTitleModal], ); + const handleTitleConfirm = useCallback(async (id: number, title: string) => { + await updateTitle(id, title); + setTitleToastVisible(true); + }, [updateTitle]); + + const showEmptyState = !isLoading && displayLinks.length === 0; + return ( {/* 헤더 */} @@ -86,12 +161,33 @@ export default function SavedLinksScreen() { /> + {errorMessage ? ( + + {errorMessage} + + ) : null} + {/* 링크 목록 */} String(item.id)} contentContainerStyle={styles.listContent} showsVerticalScrollIndicator={false} + refreshControl={ + { + void refreshLinks(); + }} + tintColor={Colors.brand.primary} + /> + } + onEndReached={() => { + if (hasNext && !isLoadingMore) { + void loadMoreLinks(); + } + }} + onEndReachedThreshold={0.4} renderItem={({ item }) => ( toggleBookmark(item.id)} + onBookmark={() => { + void handleBookmark(item.id); + }} onMore={(anchor) => openMoreMenu(item, anchor)} /> )} ItemSeparatorComponent={() => } + ListEmptyComponent={ + showEmptyState ? ( + + 저장한 링크가 없어요 + 검사 결과 화면에서 안전한 링크를 저장해보세요 + + ) : null + } + ListFooterComponent={ + isLoadingMore ? ( + + + + ) : null + } /> {/* 링크 컨텍스트 메뉴 */} @@ -113,6 +226,27 @@ export default function SavedLinksScreen() { items={menuItems} onDismiss={() => setMenuVisible(false)} /> + + setEditingLink(null)} + /> + + setDeleteToastVisible(false)} + /> + setTitleToastVisible(false)} + /> ); } @@ -143,6 +277,7 @@ const styles = StyleSheet.create({ zIndex: 10, }, listContent: { + flexGrow: 1, paddingHorizontal: 20, paddingTop: 4, paddingBottom: 32, @@ -150,4 +285,37 @@ const styles = StyleSheet.create({ separator: { height: 12, }, + errorBox: { + marginHorizontal: 20, + marginBottom: 8, + borderRadius: 12, + borderWidth: 1, + borderColor: Colors.brand.textWarning, + backgroundColor: Colors.brand.surface, + paddingHorizontal: 14, + paddingVertical: 10, + }, + errorText: { + ...Typography.caption, + color: Colors.brand.textWarning, + }, + emptyState: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + gap: 6, + paddingBottom: 80, + }, + emptyText: { + ...Typography.body, + color: Colors.brand.textSecondary, + }, + emptySubtext: { + ...Typography.caption, + color: Colors.brand.textHint, + }, + footerLoader: { + paddingVertical: 16, + alignItems: 'center', + }, }); diff --git a/app/(tabs)/(home)/scan-result-caution.tsx b/app/(tabs)/(home)/scan-result-caution.tsx index 2cf9107..6f96c8b 100644 --- a/app/(tabs)/(home)/scan-result-caution.tsx +++ b/app/(tabs)/(home)/scan-result-caution.tsx @@ -1,11 +1,11 @@ import { useEffect, useState } from 'react'; import { router, Stack, useLocalSearchParams } from 'expo-router'; -import { Linking, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { Alert, Linking, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { ResultStatusIcon } from '@/components/ui/result-status-icon'; import { ScanResultReason } from '@/components/ui/scan-result-reason'; import { getMockScanResultReason } from '@/constants/scan-result-reasons'; import { Colors, Typography } from '@/constants/theme'; -import { useSavedLinks } from '@/context/saved-links-context'; +import { getSavedLinkErrorMessage, useSavedLinks } from '@/context/saved-links-context'; import { LinkSaveModal } from '@/components/ui/link-save-modal'; import { useAnalysisResult } from '@/hooks/use-analysis-result'; import { @@ -14,7 +14,6 @@ import { getAnalysisReasonText, getAnalysisResultPath, getRouteParam, - getSiteName, } from '@/utils/analysis-result-display'; export default function ScanResultCautionScreen() { @@ -30,9 +29,16 @@ export default function ScanResultCautionScreen() { const finalUrl = getAnalysisFinalUrl(analysis, displayUrl); const reason = getAnalysisReasonText(analysis, getMockScanResultReason('caution')); const [saveModalVisible, setSaveModalVisible] = useState(false); + const [isSaving, setIsSaving] = useState(false); const shouldRedirectToVerdict = Boolean(analysis?.verdict && analysis.verdict !== 'caution'); const isVerifyingAnalysis = Boolean(analysisId) && !errorMessage && (!analysis?.verdict || isLoading); - const canSave = displayUrl.trim().length > 0 && !isLoading && !shouldRedirectToVerdict; + const saveAnalysisId = analysis?.analysisId ?? analysisId; + const canSave = + Boolean(saveAnalysisId) && + displayUrl.trim().length > 0 && + !isLoading && + !shouldRedirectToVerdict && + !isSaving; useEffect(() => { if (!analysis?.verdict || analysis.verdict === 'caution') { @@ -49,27 +55,33 @@ export default function ScanResultCautionScreen() { }); }, [analysis, url]); - const handleSave = (title: string) => { - if (!canSave) { + const handleSave = async (title: string) => { + if (!canSave || !saveAnalysisId) { return; } - // TODO: POST /api/v1/saved-links { analysisId } 호출 후 응답으로 교체 - addLink({ - id: Date.now(), - analysisId: analysis?.analysisId ?? analysisId ?? `mock-${Date.now()}`, - categoryId: null, - originalUrl: displayUrl, - finalUrl: finalUrl || null, - title, - description: analysis?.summary ?? '저장된 링크입니다.', - siteName: getSiteName(finalUrl || displayUrl), - verdict: analysis?.verdict ?? 'caution', - isBookmarked: false, - createdAt: new Date().toISOString(), - }); - setSaveModalVisible(false); - router.dismissAll(); + setIsSaving(true); + + try { + await addLink({ + analysisId: saveAnalysisId, + categoryId: null, + title, + description: analysis?.summary ?? '저장된 링크입니다.', + }); + setSaveModalVisible(false); + router.replace({ + pathname: '/(tabs)/(home)', + params: { savedLinkToast: String(Date.now()) }, + }); + } catch (error) { + Alert.alert( + '저장 실패', + getSavedLinkErrorMessage(error, '링크를 저장하지 못했습니다. 잠시 후 다시 시도해주세요.'), + ); + } finally { + setIsSaving(false); + } }; const handleOpenUrl = async () => { @@ -162,7 +174,12 @@ export default function ScanResultCautionScreen() { setSaveModalVisible(false)} + loading={isSaving} + onCancel={() => { + if (!isSaving) { + setSaveModalVisible(false); + } + }} onSave={handleSave} /> diff --git a/app/(tabs)/(home)/scan-result.tsx b/app/(tabs)/(home)/scan-result.tsx index 9eca83a..889a50b 100644 --- a/app/(tabs)/(home)/scan-result.tsx +++ b/app/(tabs)/(home)/scan-result.tsx @@ -1,11 +1,11 @@ import { useEffect, useState } from 'react'; import { router, Stack, useLocalSearchParams } from 'expo-router'; -import { Linking, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { Alert, Linking, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { ResultStatusIcon } from '@/components/ui/result-status-icon'; import { ScanResultReason } from '@/components/ui/scan-result-reason'; import { getMockScanResultReason } from '@/constants/scan-result-reasons'; import { Colors, Typography } from '@/constants/theme'; -import { useSavedLinks } from '@/context/saved-links-context'; +import { getSavedLinkErrorMessage, useSavedLinks } from '@/context/saved-links-context'; import { LinkSaveModal } from '@/components/ui/link-save-modal'; import { useAnalysisResult } from '@/hooks/use-analysis-result'; import { @@ -14,16 +14,8 @@ import { getAnalysisReasonText, getAnalysisResultPath, getRouteParam, - getSiteName, } from '@/utils/analysis-result-display'; -// TODO: 백엔드 연동 시 아래 흐름으로 교체 -// 1. scanning.tsx에서 POST /api/v1/analyses → analysisId 수신 후 params로 전달 -// 2. 여기서 POST /api/v1/saved-links { analysisId } 호출 -// 3. 응답(id, title, siteName 등)을 addLink에 전달 -// ERD: SAVED_LINK.analysis_id → ANALYSIS.analysis_id (FK) -// API 명세: Draft of the specification.md > 4.1 링크 저장 참고 - export default function ScanResultScreen() { const { analysisId: analysisIdParam, @@ -37,9 +29,16 @@ export default function ScanResultScreen() { const finalUrl = getAnalysisFinalUrl(analysis, displayUrl); const reason = getAnalysisReasonText(analysis, getMockScanResultReason('safe')); const [saveModalVisible, setSaveModalVisible] = useState(false); + const [isSaving, setIsSaving] = useState(false); const shouldRedirectToVerdict = Boolean(analysis?.verdict && analysis.verdict !== 'safe'); const isVerifyingAnalysis = Boolean(analysisId) && !errorMessage && (!analysis?.verdict || isLoading); - const canSave = displayUrl.trim().length > 0 && !isLoading && !shouldRedirectToVerdict; + const saveAnalysisId = analysis?.analysisId ?? analysisId; + const canSave = + Boolean(saveAnalysisId) && + displayUrl.trim().length > 0 && + !isLoading && + !shouldRedirectToVerdict && + !isSaving; useEffect(() => { if (!analysis?.verdict || analysis.verdict === 'safe') { @@ -56,28 +55,33 @@ export default function ScanResultScreen() { }); }, [analysis, url]); - const handleSave = (title: string) => { - if (!canSave) { + const handleSave = async (title: string) => { + if (!canSave || !saveAnalysisId) { return; } - // TODO: POST /api/v1/saved-links { analysisId } 호출 후 응답으로 교체 - // 현재는 URL 기반 mock 데이터로 즉시 추가 - addLink({ - id: Date.now(), - analysisId: analysis?.analysisId ?? analysisId ?? `mock-${Date.now()}`, - categoryId: null, - originalUrl: displayUrl, - finalUrl: finalUrl || null, - title, - description: analysis?.summary ?? '저장된 링크입니다.', - siteName: getSiteName(finalUrl || displayUrl), - verdict: analysis?.verdict ?? 'safe', - isBookmarked: false, - createdAt: new Date().toISOString(), - }); - setSaveModalVisible(false); - router.dismissAll(); + setIsSaving(true); + + try { + await addLink({ + analysisId: saveAnalysisId, + categoryId: null, + title, + description: analysis?.summary ?? '저장된 링크입니다.', + }); + setSaveModalVisible(false); + router.replace({ + pathname: '/(tabs)/(home)', + params: { savedLinkToast: String(Date.now()) }, + }); + } catch (error) { + Alert.alert( + '저장 실패', + getSavedLinkErrorMessage(error, '링크를 저장하지 못했습니다. 잠시 후 다시 시도해주세요.'), + ); + } finally { + setIsSaving(false); + } }; const handleOpenUrl = async () => { @@ -170,7 +174,12 @@ export default function ScanResultScreen() { setSaveModalVisible(false)} + loading={isSaving} + onCancel={() => { + if (!isSaving) { + setSaveModalVisible(false); + } + }} onSave={handleSave} /> diff --git a/components/ui/link-save-modal.tsx b/components/ui/link-save-modal.tsx index 44f72d0..cc15e55 100644 --- a/components/ui/link-save-modal.tsx +++ b/components/ui/link-save-modal.tsx @@ -18,6 +18,7 @@ interface LinkSaveModalProps { url: string; // 향후 링크카드 more 버튼의 URL 제목 수정 기능에서 기존 제목을 초기값으로 사용합니다. initialTitle?: string; + loading?: boolean; onCancel: () => void; onSave: (title: string) => void; } @@ -26,13 +27,14 @@ export function LinkSaveModal({ visible, url, initialTitle = '', + loading = false, onCancel, onSave, }: LinkSaveModalProps) { const [title, setTitle] = useState(initialTitle); const trimmedTitle = title.trim(); - const saveDisabled = trimmedTitle.length === 0; + const saveDisabled = loading || trimmedTitle.length === 0; useEffect(() => { setTitle(initialTitle); @@ -54,7 +56,7 @@ export function LinkSaveModal({ style={styles.overlay} behavior={Platform.OS === 'ios' ? 'padding' : undefined} > - + @@ -74,7 +76,8 @@ export function LinkSaveModal({ placeholderTextColor={Colors.brand.textHint} returnKeyType="done" onSubmitEditing={handleSave} - maxLength={50} + maxLength={500} + editable={!loading} autoFocus /> @@ -93,6 +96,7 @@ export function LinkSaveModal({ style={styles.cancelButton} onPress={onCancel} activeOpacity={0.8} + disabled={loading} > 취소 @@ -104,7 +108,7 @@ export function LinkSaveModal({ disabled={saveDisabled} > - 저장 + {loading ? '저장 중...' : '저장'} diff --git a/components/ui/title-edit-modal.tsx b/components/ui/title-edit-modal.tsx new file mode 100644 index 0000000..4593c06 --- /dev/null +++ b/components/ui/title-edit-modal.tsx @@ -0,0 +1,226 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + Alert, + KeyboardAvoidingView, + Modal, + Platform, + Pressable, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; + +import { Colors, Typography } from '@/constants/theme'; +import { getSavedLinkErrorMessage, type SavedLink } from '@/context/saved-links-context'; + +interface TitleEditModalProps { + editingLink: SavedLink | null; + onConfirm: (id: number, title: string) => Promise; + onClose: () => void; +} + +export function TitleEditModal({ editingLink, onConfirm, onClose }: TitleEditModalProps) { + const [titleValue, setTitleValue] = useState(''); + const [isUpdatingTitle, setIsUpdatingTitle] = useState(false); + const titleInputRef = useRef(null); + + useEffect(() => { + if (!editingLink) { + setTitleValue(''); + return; + } + + setTitleValue(editingLink.title); + const focusTimer = setTimeout(() => titleInputRef.current?.focus(), 100); + + return () => clearTimeout(focusTimer); + }, [editingLink]); + + const handleClose = useCallback(() => { + if (isUpdatingTitle) { + return; + } + + onClose(); + }, [isUpdatingTitle, onClose]); + + const handleConfirm = useCallback(async () => { + const trimmedTitle = titleValue.trim(); + + if (!editingLink || trimmedTitle.length === 0 || trimmedTitle.length > 500 || isUpdatingTitle) { + return; + } + + setIsUpdatingTitle(true); + + try { + await onConfirm(editingLink.id, trimmedTitle); + onClose(); + } catch (error) { + Alert.alert( + '제목 수정 실패', + getSavedLinkErrorMessage(error, '제목을 수정하지 못했습니다. 잠시 후 다시 시도해주세요.'), + ); + } finally { + setIsUpdatingTitle(false); + } + }, [editingLink, isUpdatingTitle, onClose, onConfirm, titleValue]); + + const trimmedTitleValue = titleValue.trim(); + const titleSubmitDisabled = + trimmedTitleValue.length === 0 || + trimmedTitleValue.length > 500 || + trimmedTitleValue === editingLink?.title.trim() || + isUpdatingTitle; + + return ( + + + + + 제목 수정 + + + {titleValue.length > 0 && !isUpdatingTitle && ( + setTitleValue('')} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + style={styles.clearButton} + > + + + )} + + + + 취소 + + + + {isUpdatingTitle ? '저장 중...' : '저장'} + + + + + + + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + justifyContent: 'flex-end', + }, + backdrop: { + ...StyleSheet.absoluteFillObject, + backgroundColor: Colors.brand.overlayBackdrop, + }, + sheet: { + backgroundColor: Colors.brand.surface, + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + padding: 24, + paddingBottom: 40, + gap: 16, + }, + sheetTitle: { + ...Typography.section, + color: Colors.brand.text, + textAlign: 'center', + }, + inputRow: { + flexDirection: 'row', + alignItems: 'center', + borderWidth: 1.5, + borderColor: Colors.brand.line, + borderRadius: 12, + paddingHorizontal: 16, + paddingVertical: 12, + }, + input: { + flex: 1, + ...Typography.body, + color: Colors.brand.text, + padding: 0, + }, + clearButton: { + marginLeft: 8, + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: Colors.brand.softMint, + alignItems: 'center', + justifyContent: 'center', + }, + clearButtonText: { + ...Typography.caption, + color: Colors.brand.primary, + lineHeight: 16, + }, + actions: { + flexDirection: 'row', + gap: 12, + }, + cancelBtn: { + flex: 1, + paddingVertical: 14, + borderRadius: 12, + borderWidth: 1.5, + borderColor: Colors.brand.line, + alignItems: 'center', + }, + cancelText: { + ...Typography.body, + fontWeight: '700', + color: Colors.brand.textSecondary, + }, + confirmBtn: { + flex: 1, + paddingVertical: 14, + borderRadius: 12, + backgroundColor: Colors.brand.primary, + alignItems: 'center', + }, + confirmBtnDisabled: { + backgroundColor: Colors.brand.softMint, + }, + confirmText: { + ...Typography.body, + fontWeight: '700', + color: Colors.brand.onPrimary, + }, + confirmTextDisabled: { + color: Colors.brand.textHint, + }, +}); diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx index b41b1b2..1d1a448 100644 --- a/components/ui/toast.tsx +++ b/components/ui/toast.tsx @@ -7,10 +7,19 @@ interface ToastProps { visible: boolean; message: string; duration?: number; + placement?: 'center' | 'top'; + topOffset?: number; onHide?: () => void; } -export function Toast({ visible, message, duration = 2500, onHide }: ToastProps) { +export function Toast({ + visible, + message, + duration = 2500, + placement = 'center', + topOffset = 64, + onHide, +}: ToastProps) { const opacity = useRef(new Animated.Value(0)).current; useEffect(() => { @@ -26,7 +35,14 @@ export function Toast({ visible, message, duration = 2500, onHide }: ToastProps) if (!visible) return null; return ( - + {message} @@ -38,15 +54,20 @@ export function Toast({ visible, message, duration = 2500, onHide }: ToastProps) const styles = StyleSheet.create({ container: { position: 'absolute', - top: 0, - bottom: 0, left: 0, right: 0, zIndex: 100, - justifyContent: 'center', alignItems: 'center', paddingHorizontal: 16, }, + centerContainer: { + top: 0, + bottom: 0, + justifyContent: 'center', + }, + topContainer: { + justifyContent: 'flex-start', + }, toast: { flexDirection: 'row', alignItems: 'center', diff --git a/context/saved-links-context.tsx b/context/saved-links-context.tsx index 9c990e1..1db1a70 100644 --- a/context/saved-links-context.tsx +++ b/context/saved-links-context.tsx @@ -1,177 +1,239 @@ -import { createContext, useCallback, useContext, useState } from 'react'; - -// ─── Types ──────────────────────────────────────────────────────────────────── -// ERD: SAVED_LINK JOIN ANALYSIS / API 명세 4.2 응답 구조 기반 - -export interface SavedLink { - id: number; - analysisId: string; - categoryId: number | null; - originalUrl: string; - finalUrl: string | null; - title: string; +import { useAuth } from '@clerk/expo'; +import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'; + +import { + createSavedLink, + deleteSavedLink, + fetchSavedLinks, + toggleSavedLinkBookmark, + updateSavedLinkCategory, + updateSavedLinkTitle, + type SavedLinkCreateRequest, + type SavedLinkListQuery, + type SavedLinkResponse, +} from '@/api/saved-links'; +import { getSiteName } from '@/utils/analysis-result-display'; + +const DEFAULT_PAGE_SIZE = 50; +const LOGIN_REQUIRED_MESSAGE = + '로그인 상태를 확인할 수 없습니다. 다시 로그인한 뒤 시도해주세요.'; + +export interface SavedLink extends SavedLinkResponse { description: string; siteName: string; - verdict: 'safe' | 'caution' | 'danger'; - isBookmarked: boolean; - createdAt: string; } interface SavedLinksContextValue { links: SavedLink[]; - addLink: (link: SavedLink) => void; - toggleBookmark: (id: number) => void; - deleteLink: (id: number) => void; - assignCategory: (ids: number[], categoryId: number | null) => void; + isLoading: boolean; + isLoadingMore: boolean; + errorMessage: string; + hasNext: boolean; + nextCursor: string | null; + refreshLinks: (query?: SavedLinkListQuery) => Promise; + loadMoreLinks: () => Promise; + addLink: (request: SavedLinkCreateRequest) => Promise; + toggleBookmark: (id: number) => Promise; + deleteLink: (id: number) => Promise; + updateTitle: (id: number, title: string) => Promise; + assignCategory: (ids: number[], categoryId: number | null) => Promise; } -// ─── Mock Data ──────────────────────────────────────────────────────────────── -// 실제 구현 시 GET /api/v1/saved-links 응답으로 교체 - -const MOCK_LINKS: SavedLink[] = [ - { - id: 1, - analysisId: 'a1b2c3d4-0001-0001-0001-000000000001', - categoryId: 1, - originalUrl: 'https://blog.naver.com/food1', - finalUrl: 'https://blog.naver.com/food1', - title: '용인시장 맛집 Top 100', - description: '요약된 한줄짜리 글~', - siteName: 'NAVER', - verdict: 'safe', - isBookmarked: false, - createdAt: '2026-04-24T10:00:00Z', - }, - { - id: 2, - analysisId: 'a1b2c3d4-0002-0002-0002-000000000002', - categoryId: 1, - originalUrl: 'https://blog.naver.com/food2', - finalUrl: 'https://blog.naver.com/food2', - title: '명지대학교 맛집', - description: '요약된 한줄짜리 글~', - siteName: 'NAVER', - verdict: 'safe', - isBookmarked: true, - createdAt: '2026-04-24T09:00:00Z', - }, - { - id: 3, - analysisId: 'a1b2c3d4-0003-0003-0003-000000000003', - categoryId: 2, - originalUrl: 'https://blog.naver.com/study1', - finalUrl: 'https://blog.naver.com/study1', - title: '알고리즘 이론', - description: '요약된 한줄짜리 글~', - siteName: 'NAVER', - verdict: 'safe', - isBookmarked: false, - createdAt: '2026-04-24T08:00:00Z', - }, - { - id: 4, - analysisId: 'a1b2c3d4-0004-0004-0004-000000000004', - categoryId: 2, - originalUrl: 'https://velog.io/typescript', - finalUrl: 'https://velog.io/typescript', - title: 'TypeScript 완전 정복', - description: '요약된 한줄짜리 글~', - siteName: 'Velog', - verdict: 'safe', - isBookmarked: true, - createdAt: '2026-04-23T10:00:00Z', - }, - { - id: 5, - analysisId: 'a1b2c3d4-0005-0005-0005-000000000005', - categoryId: null, - originalUrl: 'https://www.notion.so/LinClean', - finalUrl: 'https://www.notion.so/LinClean', - title: 'LinClean 기획서', - description: '요약된 한줄짜리 글~', - siteName: 'Notion', - verdict: 'caution', - isBookmarked: true, - createdAt: '2026-04-23T08:00:00Z', - }, - { - id: 6, - analysisId: 'a1b2c3d4-0006-0006-0006-000000000006', - categoryId: 3, - originalUrl: 'https://blog.naver.com/invest1', - finalUrl: 'https://blog.naver.com/invest1', - title: '주식 투자 꿀팁 모음', - description: '요약된 한줄짜리 글~', - siteName: 'NAVER', - verdict: 'safe', - isBookmarked: false, - createdAt: '2026-04-22T10:00:00Z', - }, - { - id: 7, - analysisId: 'a1b2c3d4-0007-0007-0007-000000000007', - categoryId: 2, - originalUrl: 'https://youtube.com/rn', - finalUrl: 'https://youtube.com/rn', - title: 'React Native 강의', - description: '요약된 한줄짜리 글~', - siteName: 'YouTube', - verdict: 'safe', - isBookmarked: true, - createdAt: '2026-04-22T08:00:00Z', - }, - { - id: 8, - analysisId: 'a1b2c3d4-0008-0008-0008-000000000008', - categoryId: null, - originalUrl: 'https://github.com/expo-router', - finalUrl: 'https://github.com/expo-router', - title: 'Expo Router 공식 문서', - description: '요약된 한줄짜리 글~', - siteName: 'GitHub', - verdict: 'safe', - isBookmarked: false, - createdAt: '2026-04-21T10:00:00Z', - }, -]; - -// ─── Context ────────────────────────────────────────────────────────────────── - const SavedLinksContext = createContext(null); export function SavedLinksProvider({ children }: { children: React.ReactNode }) { - const [links, setLinks] = useState(MOCK_LINKS); + const { getToken, isLoaded, isSignedIn } = useAuth(); + const [links, setLinks] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + const [hasNext, setHasNext] = useState(false); + const [nextCursor, setNextCursor] = useState(null); + const lastQueryRef = useRef({ size: DEFAULT_PAGE_SIZE }); + const getTokenRef = useRef(getToken); + + useEffect(() => { + getTokenRef.current = getToken; + }, [getToken]); + + const refreshLinks = useCallback( + async (query: SavedLinkListQuery = {}) => { + if (!isLoaded) { + return; + } + + if (!isSignedIn) { + setLinks([]); + setErrorMessage(LOGIN_REQUIRED_MESSAGE); + setHasNext(false); + setNextCursor(null); + return; + } + + const nextQuery = { size: DEFAULT_PAGE_SIZE, ...query, cursor: null }; + lastQueryRef.current = nextQuery; + setIsLoading(true); + setErrorMessage(''); + + try { + const response = await fetchSavedLinks(() => getTokenRef.current(), nextQuery); + setLinks(response.items.map(normalizeSavedLink)); + setHasNext(response.hasNext); + setNextCursor(response.nextCursor); + } catch (error) { + setErrorMessage(getSavedLinkErrorMessage(error, '저장한 링크를 불러오지 못했습니다.')); + } finally { + setIsLoading(false); + } + }, + [isLoaded, isSignedIn], + ); + + const loadMoreLinks = useCallback(async () => { + if (!isLoaded || !isSignedIn || !hasNext || !nextCursor || isLoadingMore) { + return; + } - const addLink = useCallback((link: SavedLink) => { - // TODO: 백엔드 연동 시 POST /api/v1/saved-links { analysisId } 호출 후 응답으로 교체 - setLinks((prev) => [link, ...prev]); + setIsLoadingMore(true); + setErrorMessage(''); + + try { + const response = await fetchSavedLinks(() => getTokenRef.current(), { + ...lastQueryRef.current, + cursor: nextCursor, + }); + + setLinks((prev) => mergeLinks(prev, response.items.map(normalizeSavedLink))); + setHasNext(response.hasNext); + setNextCursor(response.nextCursor); + } catch (error) { + setErrorMessage(getSavedLinkErrorMessage(error, '저장한 링크를 더 불러오지 못했습니다.')); + } finally { + setIsLoadingMore(false); + } + }, [hasNext, isLoaded, isLoadingMore, isSignedIn, nextCursor]); + + useEffect(() => { + if (!isLoaded) { + return; + } + + if (!isSignedIn) { + setLinks([]); + setHasNext(false); + setNextCursor(null); + setErrorMessage(''); + return; + } + + void refreshLinks(); + }, [isLoaded, isSignedIn, refreshLinks]); + + const addLink = useCallback(async (request: SavedLinkCreateRequest) => { + const savedLink = normalizeSavedLink( + await createSavedLink(() => getTokenRef.current(), request), + ); + setLinks((prev) => [savedLink, ...prev.filter((link) => link.id !== savedLink.id)]); + return savedLink; }, []); - const toggleBookmark = useCallback((id: number) => { - // 실제 구현 시 PATCH /api/v1/saved-links/{id}/bookmark 호출 + const toggleBookmark = useCallback(async (id: number) => { + const previousLinks = links; setLinks((prev) => prev.map((link) => - link.id === id ? { ...link, isBookmarked: !link.isBookmarked } : link - ) + link.id === id ? { ...link, isBookmarked: !link.isBookmarked } : link, + ), ); - }, []); - const deleteLink = useCallback((id: number) => { - // 실제 구현 시 DELETE /api/v1/saved-links/{id} 호출 + try { + const response = await toggleSavedLinkBookmark(() => getTokenRef.current(), id); + setLinks((prev) => + prev.map((link) => + link.id === id ? { ...link, isBookmarked: response.isBookmarked } : link, + ), + ); + } catch (error) { + setLinks(previousLinks); + throw error; + } + }, [links]); + + const deleteLink = useCallback(async (id: number) => { + const previousLinks = links; setLinks((prev) => prev.filter((link) => link.id !== id)); - }, []); - const assignCategory = useCallback((ids: number[], categoryId: number | null) => { - // 실제 구현 시 ids.forEach → PATCH /api/v1/saved-links/{id} { categoryId } 호출 + try { + await deleteSavedLink(() => getTokenRef.current(), id); + } catch (error) { + setLinks(previousLinks); + throw error; + } + }, [links]); + + const updateTitle = useCallback(async (id: number, title: string) => { + const previousLinks = links; setLinks((prev) => - prev.map((link) => - ids.includes(link.id) ? { ...link, categoryId } : link - ) + prev.map((link) => (link.id === id ? { ...link, title } : link)), ); - }, []); + + try { + const response = await updateSavedLinkTitle(() => getTokenRef.current(), id, title); + setLinks((prev) => + prev.map((link) => + link.id === id ? { ...link, title: response.title } : link, + ), + ); + } catch (error) { + setLinks(previousLinks); + throw error; + } + }, [links]); + + const assignCategory = useCallback(async (ids: number[], categoryId: number | null) => { + if (ids.length === 0) { + return; + } + + const previousLinks = links; + setLinks((prev) => + prev.map((link) => (ids.includes(link.id) ? { ...link, categoryId } : link)), + ); + + try { + const responses = await Promise.all( + ids.map((id) => updateSavedLinkCategory(() => getTokenRef.current(), id, categoryId)), + ); + + setLinks((prev) => + prev.map((link) => { + const response = responses.find((item) => item.id === link.id); + return response ? { ...link, categoryId: response.categoryId } : link; + }), + ); + } catch (error) { + setLinks(previousLinks); + throw error; + } + }, [links]); return ( - + {children} ); @@ -182,3 +244,39 @@ export function useSavedLinks(): SavedLinksContextValue { if (!ctx) throw new Error('useSavedLinks must be used within SavedLinksProvider'); return ctx; } + +export function getSavedLinkErrorMessage(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 normalizeSavedLink(link: SavedLinkResponse): SavedLink { + const displayUrl = link.finalUrl ?? link.originalUrl; + + return { + ...link, + finalUrl: link.finalUrl ?? null, + categoryId: link.categoryId ?? null, + description: link.description ?? '', + siteName: getSiteName(displayUrl), + }; +} + +function mergeLinks(previousLinks: SavedLink[], nextLinks: SavedLink[]) { + const linkMap = new Map(); + + [...previousLinks, ...nextLinks].forEach((link) => { + linkMap.set(link.id, link); + }); + + return [...linkMap.values()]; +}