From a3d7a024bf48c405bf7d11547d894c7ed13fd2b1 Mon Sep 17 00:00:00 2001 From: KimByeongHun Date: Thu, 21 May 2026 22:28:45 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=EC=A0=80=EC=9E=A5=20=EB=A7=81?= =?UTF-8?q?=ED=81=AC=20API=20=EC=A0=80=EC=9E=A5=20=ED=9D=90=EB=A6=84=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/saved-links.ts | 146 ++++++++ app/(tabs)/(home)/scan-result-caution.tsx | 62 ++-- app/(tabs)/(home)/scan-result.tsx | 70 ++-- components/ui/link-save-modal.tsx | 12 +- context/saved-links-context.tsx | 398 ++++++++++++++-------- 5 files changed, 480 insertions(+), 208 deletions(-) create mode 100644 api/saved-links.ts diff --git a/api/saved-links.ts b/api/saved-links.ts new file mode 100644 index 0000000..eaef056 --- /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) { + 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)/scan-result-caution.tsx b/app/(tabs)/(home)/scan-result-caution.tsx index 2cf9107..8c2ef31 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,32 @@ 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); + Alert.alert('저장 완료', '주의 링크가 저장되었습니다.', [ + { text: '확인', onPress: () => router.dismissAll() }, + ]); + } catch (error) { + Alert.alert( + '저장 실패', + getSavedLinkErrorMessage(error, '링크를 저장하지 못했습니다. 잠시 후 다시 시도해주세요.'), + ); + } finally { + setIsSaving(false); + } }; const handleOpenUrl = async () => { @@ -162,7 +173,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..df777df 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,32 @@ 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); + Alert.alert('저장 완료', '링크가 저장되었습니다.', [ + { text: '확인', onPress: () => router.dismissAll() }, + ]); + } catch (error) { + Alert.alert( + '저장 실패', + getSavedLinkErrorMessage(error, '링크를 저장하지 못했습니다. 잠시 후 다시 시도해주세요.'), + ); + } finally { + setIsSaving(false); + } }; const handleOpenUrl = async () => { @@ -170,7 +173,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/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()]; +} From a0e68fef54b1aa558f004880a4039b01d34de7b7 Mon Sep 17 00:00:00 2001 From: KimByeongHun Date: Thu, 21 May 2026 22:29:11 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20=EC=A0=80=EC=9E=A5=20=EB=A7=81?= =?UTF-8?q?=ED=81=AC=20=EB=AA=A9=EB=A1=9D=20=EA=B4=80=EB=A6=AC=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/(home)/saved-links.tsx | 351 +++++++++++++++++++++++++++++- 1 file changed, 340 insertions(+), 11 deletions(-) diff --git a/app/(tabs)/(home)/saved-links.tsx b/app/(tabs)/(home)/saved-links.tsx index 6b1cbe1..7828708 100644 --- a/app/(tabs)/(home)/saved-links.tsx +++ b/app/(tabs)/(home)/saved-links.tsx @@ -1,5 +1,19 @@ -import { useState, useMemo, useCallback } from 'react'; -import { FlatList, StyleSheet, Text, View } from 'react-native'; +import { useState, useMemo, useCallback, useRef } from 'react'; +import { + ActivityIndicator, + Alert, + FlatList, + KeyboardAvoidingView, + Modal, + Platform, + Pressable, + RefreshControl, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { router } from 'expo-router'; import { AppIcon } from '@/components/ui/app-icon'; @@ -9,7 +23,7 @@ import { FilterChip, type FilterChipItem } from '@/components/ui/filter-chip'; import { FolderContextMenu, type ContextMenuItem } from '@/components/ui/folder-context-menu'; 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 +39,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 +58,10 @@ export default function SavedLinksScreen() { const [menuVisible, setMenuVisible] = useState(false); const [menuAnchor, setMenuAnchor] = useState(); const [menuItems, setMenuItems] = useState([]); + const [editingLink, setEditingLink] = useState(null); + const [titleValue, setTitleValue] = useState(''); + const [isUpdatingTitle, setIsUpdatingTitle] = useState(false); + const titleInputRef = useRef(null); const displayLinks = useMemo(() => { const folderFiltered = @@ -40,30 +69,110 @@ 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); + setTitleValue(link.title); + setTimeout(() => titleInputRef.current?.focus(), 100); + }, []); + + 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); + } 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 handleTitleCancel = useCallback(() => { + if (isUpdatingTitle) { + return; + } + + setEditingLink(null); + setTitleValue(''); + }, [isUpdatingTitle]); + + const handleTitleConfirm = useCallback(async () => { + const trimmedTitle = titleValue.trim(); + + if (!editingLink || trimmedTitle.length === 0 || trimmedTitle.length > 500 || isUpdatingTitle) { + return; + } + + setIsUpdatingTitle(true); + + try { + await updateTitle(editingLink.id, trimmedTitle); + setEditingLink(null); + setTitleValue(''); + } catch (error) { + Alert.alert( + '제목 수정 실패', + getSavedLinkErrorMessage(error, '제목을 수정하지 못했습니다. 잠시 후 다시 시도해주세요.'), + ); + } finally { + setIsUpdatingTitle(false); + } + }, [editingLink, isUpdatingTitle, titleValue, updateTitle]); + + const titleSubmitDisabled = titleValue.trim().length === 0 || titleValue.trim().length > 500 || isUpdatingTitle; + const showEmptyState = !isLoading && displayLinks.length === 0; + return ( {/* 헤더 */} @@ -86,12 +195,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 +260,66 @@ export default function SavedLinksScreen() { items={menuItems} onDismiss={() => setMenuVisible(false)} /> + + {/* 링크 제목 수정 모달 */} + + + + + 제목 수정 + + + {titleValue.length > 0 && !isUpdatingTitle && ( + setTitleValue('')} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + style={renameStyles.clearButton} + > + × + + )} + + + + 취소 + + + + {isUpdatingTitle ? '저장 중...' : '저장'} + + + + + + ); } @@ -143,6 +350,7 @@ const styles = StyleSheet.create({ zIndex: 10, }, listContent: { + flexGrow: 1, paddingHorizontal: 20, paddingTop: 4, paddingBottom: 32, @@ -150,4 +358,125 @@ 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', + }, +}); + +const renameStyles = 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, + }, }); From eaae74c8a3b531b047bbc4bdcbad2a62acb48baf Mon Sep 17 00:00:00 2001 From: KimByeongHun Date: Thu, 21 May 2026 22:29:39 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20=ED=99=88=20=EC=B5=9C=EA=B7=BC=20?= =?UTF-8?q?=EB=A7=81=ED=81=AC=20=EC=A0=9C=EB=AA=A9=20=EC=88=98=EC=A0=95=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 --- app/(tabs)/(home)/index.tsx | 258 ++++++++++++++++++++++++++++++++++-- 1 file changed, 250 insertions(+), 8 deletions(-) diff --git a/app/(tabs)/(home)/index.tsx b/app/(tabs)/(home)/index.tsx index 1e74d9d..4b19761 100644 --- a/app/(tabs)/(home)/index.tsx +++ b/app/(tabs)/(home)/index.tsx @@ -1,5 +1,17 @@ -import { useState } from 'react'; -import { ScrollView, StyleSheet, Text, View } from 'react-native'; +import { useCallback, useRef, useState } from 'react'; +import { + Alert, + KeyboardAvoidingView, + Modal, + Platform, + Pressable, + ScrollView, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { router } from 'expo-router'; import { AppIcon } from '@/components/ui/app-icon'; @@ -8,12 +20,16 @@ import { FolderContextMenu } from '@/components/ui/folder-context-menu'; import { IconSymbol } from '@/components/ui/icon-symbol'; import { SectionHeader } from '@/components/ui/section-header'; 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 { links, toggleBookmark, deleteLink, updateTitle } = useSavedLinks(); const [menuState, setMenuState] = useState<{ visible: boolean; anchor?: AnchorPosition; linkId?: number }>({ visible: false }); + const [editingLink, setEditingLink] = useState(null); + const [titleValue, setTitleValue] = useState(''); + const [isUpdatingTitle, setIsUpdatingTitle] = useState(false); + const titleInputRef = useRef(null); // 최근 저장한 링크 — createdAt 내림차순 상위 3개 const recentLinks = links.slice(0, 3); @@ -24,6 +40,83 @@ export default function HomeScreen() { 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); + } catch (error) { + Alert.alert( + '삭제 실패', + getSavedLinkErrorMessage(error, '링크를 삭제하지 못했습니다. 잠시 후 다시 시도해주세요.'), + ); + } + }, + }, + ]); + }, + [deleteLink], + ); + + const openTitleModal = useCallback((link: SavedLink) => { + setEditingLink(link); + setTitleValue(link.title); + setTimeout(() => titleInputRef.current?.focus(), 100); + }, []); + + const handleTitleCancel = useCallback(() => { + if (isUpdatingTitle) { + return; + } + + setEditingLink(null); + setTitleValue(''); + }, [isUpdatingTitle]); + + const handleTitleConfirm = useCallback(async () => { + const trimmedTitle = titleValue.trim(); + + if (!editingLink || trimmedTitle.length === 0 || trimmedTitle.length > 500 || isUpdatingTitle) { + return; + } + + setIsUpdatingTitle(true); + + try { + await updateTitle(editingLink.id, trimmedTitle); + setEditingLink(null); + setTitleValue(''); + } catch (error) { + Alert.alert( + '제목 수정 실패', + getSavedLinkErrorMessage(error, '제목을 수정하지 못했습니다. 잠시 후 다시 시도해주세요.'), + ); + } finally { + setIsUpdatingTitle(false); + } + }, [editingLink, isUpdatingTitle, titleValue, updateTitle]); + + const titleSubmitDisabled = titleValue.trim().length === 0 || titleValue.trim().length > 500 || isUpdatingTitle; + return ( toggleBookmark(link.id)} + onBookmark={() => { + void handleBookmark(link.id); + }} onMore={(anchor) => handleMore(link.id, anchor)} /> ))} @@ -88,17 +183,76 @@ 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 })} /> + + + + + + 제목 수정 + + + {titleValue.length > 0 && !isUpdatingTitle && ( + setTitleValue('')} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + style={renameStyles.clearButton} + > + × + + )} + + + + 취소 + + + + {isUpdatingTitle ? '저장 중...' : '저장'} + + + + + + ); } @@ -169,3 +323,91 @@ const styles = StyleSheet.create({ gap: 12, }, }); + +const renameStyles = 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, + }, +}); From f705a4ecff075c922729c870ff1abcfe833f0c18 Mon Sep 17 00:00:00 2001 From: KimByeongHun Date: Thu, 21 May 2026 23:15:17 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20=EC=A0=80=EC=9E=A5=20=EB=A7=81?= =?UTF-8?q?=ED=81=AC=20=ED=86=A0=EC=8A=A4=ED=8A=B8=20=ED=94=BC=EB=93=9C?= =?UTF-8?q?=EB=B0=B1=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/(home)/index.tsx | 37 +++++++++++++++++++++-- app/(tabs)/(home)/saved-links.tsx | 11 +++++++ app/(tabs)/(home)/scan-result-caution.tsx | 7 +++-- app/(tabs)/(home)/scan-result.tsx | 7 +++-- components/ui/toast.tsx | 31 ++++++++++++++++--- 5 files changed, 80 insertions(+), 13 deletions(-) diff --git a/app/(tabs)/(home)/index.tsx b/app/(tabs)/(home)/index.tsx index 4b19761..343b6c0 100644 --- a/app/(tabs)/(home)/index.tsx +++ b/app/(tabs)/(home)/index.tsx @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { Alert, KeyboardAvoidingView, @@ -13,27 +13,44 @@ import { 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 { Toast } from '@/components/ui/toast'; import { Colors, Typography } from '@/constants/theme'; import { getSavedLinkErrorMessage, useSavedLinks, type SavedLink } from '@/context/saved-links-context'; import type { AnchorPosition } from '@/components/ui/folder-card'; export default function HomeScreen() { + 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 [titleValue, setTitleValue] = useState(''); const [isUpdatingTitle, setIsUpdatingTitle] = useState(false); + const [saveToastVisible, setSaveToastVisible] = useState(false); + const [deleteToastVisible, setDeleteToastVisible] = useState(false); const titleInputRef = useRef(null); + 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 }); }; @@ -64,6 +81,7 @@ export default function HomeScreen() { onPress: async () => { try { await deleteLink(id); + setDeleteToastVisible(true); } catch (error) { Alert.alert( '삭제 실패', @@ -253,6 +271,21 @@ export default function HomeScreen() { + + setSaveToastVisible(false)} + /> + setDeleteToastVisible(false)} + /> ); } diff --git a/app/(tabs)/(home)/saved-links.tsx b/app/(tabs)/(home)/saved-links.tsx index 7828708..ea19843 100644 --- a/app/(tabs)/(home)/saved-links.tsx +++ b/app/(tabs)/(home)/saved-links.tsx @@ -21,6 +21,7 @@ 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 { Toast } from '@/components/ui/toast'; import type { AnchorPosition } from '@/components/ui/folder-card'; import { Colors, Typography } from '@/constants/theme'; import { getSavedLinkErrorMessage, useSavedLinks, type SavedLink } from '@/context/saved-links-context'; @@ -61,6 +62,7 @@ export default function SavedLinksScreen() { const [editingLink, setEditingLink] = useState(null); const [titleValue, setTitleValue] = useState(''); const [isUpdatingTitle, setIsUpdatingTitle] = useState(false); + const [deleteToastVisible, setDeleteToastVisible] = useState(false); const titleInputRef = useRef(null); const displayLinks = useMemo(() => { @@ -106,6 +108,7 @@ export default function SavedLinksScreen() { onPress: async () => { try { await deleteLink(id); + setDeleteToastVisible(true); } catch (error) { Alert.alert( '삭제 실패', @@ -320,6 +323,14 @@ export default function SavedLinksScreen() { + + setDeleteToastVisible(false)} + /> ); } diff --git a/app/(tabs)/(home)/scan-result-caution.tsx b/app/(tabs)/(home)/scan-result-caution.tsx index 8c2ef31..6f96c8b 100644 --- a/app/(tabs)/(home)/scan-result-caution.tsx +++ b/app/(tabs)/(home)/scan-result-caution.tsx @@ -70,9 +70,10 @@ export default function ScanResultCautionScreen() { description: analysis?.summary ?? '저장된 링크입니다.', }); setSaveModalVisible(false); - Alert.alert('저장 완료', '주의 링크가 저장되었습니다.', [ - { text: '확인', onPress: () => router.dismissAll() }, - ]); + router.replace({ + pathname: '/(tabs)/(home)', + params: { savedLinkToast: String(Date.now()) }, + }); } catch (error) { Alert.alert( '저장 실패', diff --git a/app/(tabs)/(home)/scan-result.tsx b/app/(tabs)/(home)/scan-result.tsx index df777df..889a50b 100644 --- a/app/(tabs)/(home)/scan-result.tsx +++ b/app/(tabs)/(home)/scan-result.tsx @@ -70,9 +70,10 @@ export default function ScanResultScreen() { description: analysis?.summary ?? '저장된 링크입니다.', }); setSaveModalVisible(false); - Alert.alert('저장 완료', '링크가 저장되었습니다.', [ - { text: '확인', onPress: () => router.dismissAll() }, - ]); + router.replace({ + pathname: '/(tabs)/(home)', + params: { savedLinkToast: String(Date.now()) }, + }); } catch (error) { Alert.alert( '저장 실패', 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', From fc7d463bae064a93ec1be2f31c9f2eee37f2acfd Mon Sep 17 00:00:00 2001 From: KimByeongHun Date: Thu, 21 May 2026 23:21:59 +0900 Subject: [PATCH 5/8] =?UTF-8?q?design:=20=EC=A0=9C=EB=AA=A9=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EC=A7=80=EC=9A=B0=EA=B8=B0=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/(home)/index.tsx | 2 +- app/(tabs)/(home)/saved-links.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/(tabs)/(home)/index.tsx b/app/(tabs)/(home)/index.tsx index 343b6c0..7487eca 100644 --- a/app/(tabs)/(home)/index.tsx +++ b/app/(tabs)/(home)/index.tsx @@ -246,7 +246,7 @@ export default function HomeScreen() { hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} style={renameStyles.clearButton} > - × + )} diff --git a/app/(tabs)/(home)/saved-links.tsx b/app/(tabs)/(home)/saved-links.tsx index ea19843..7e1273e 100644 --- a/app/(tabs)/(home)/saved-links.tsx +++ b/app/(tabs)/(home)/saved-links.tsx @@ -298,7 +298,7 @@ export default function SavedLinksScreen() { hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} style={renameStyles.clearButton} > - × + )} From b3d011977b8a02aaab626a8443b7cb330939e7a4 Mon Sep 17 00:00:00 2001 From: KimByeongHun Date: Fri, 22 May 2026 00:18:44 +0900 Subject: [PATCH 6/8] =?UTF-8?q?feat:=20=EC=A0=9C=EB=AA=A9=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=ED=86=A0=EC=8A=A4=ED=8A=B8=20=EB=B0=8F=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EC=97=AC=EB=B6=80=20=EA=B2=80=EC=A6=9D=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)/(home)/index.tsx | 16 +++++++++++++++- app/(tabs)/(home)/saved-links.tsx | 16 +++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/app/(tabs)/(home)/index.tsx b/app/(tabs)/(home)/index.tsx index 7487eca..ea9bff7 100644 --- a/app/(tabs)/(home)/index.tsx +++ b/app/(tabs)/(home)/index.tsx @@ -35,6 +35,7 @@ export default function HomeScreen() { const [isUpdatingTitle, setIsUpdatingTitle] = useState(false); const [saveToastVisible, setSaveToastVisible] = useState(false); const [deleteToastVisible, setDeleteToastVisible] = useState(false); + const [titleToastVisible, setTitleToastVisible] = useState(false); const titleInputRef = useRef(null); const lastToastParamRef = useRef(undefined); const savedLinkToast = typeof savedLinkToastParam === 'string' ? savedLinkToastParam : undefined; @@ -123,6 +124,7 @@ export default function HomeScreen() { await updateTitle(editingLink.id, trimmedTitle); setEditingLink(null); setTitleValue(''); + setTitleToastVisible(true); } catch (error) { Alert.alert( '제목 수정 실패', @@ -133,7 +135,12 @@ export default function HomeScreen() { } }, [editingLink, isUpdatingTitle, titleValue, updateTitle]); - const titleSubmitDisabled = titleValue.trim().length === 0 || titleValue.trim().length > 500 || isUpdatingTitle; + const trimmedTitleValue = titleValue.trim(); + const titleSubmitDisabled = + trimmedTitleValue.length === 0 || + trimmedTitleValue.length > 500 || + trimmedTitleValue === editingLink?.title.trim() || + isUpdatingTitle; return ( @@ -286,6 +293,13 @@ export default function HomeScreen() { topOffset={96} onHide={() => setDeleteToastVisible(false)} /> + setTitleToastVisible(false)} + /> ); } diff --git a/app/(tabs)/(home)/saved-links.tsx b/app/(tabs)/(home)/saved-links.tsx index 7e1273e..609ca43 100644 --- a/app/(tabs)/(home)/saved-links.tsx +++ b/app/(tabs)/(home)/saved-links.tsx @@ -63,6 +63,7 @@ export default function SavedLinksScreen() { const [titleValue, setTitleValue] = useState(''); const [isUpdatingTitle, setIsUpdatingTitle] = useState(false); const [deleteToastVisible, setDeleteToastVisible] = useState(false); + const [titleToastVisible, setTitleToastVisible] = useState(false); const titleInputRef = useRef(null); const displayLinks = useMemo(() => { @@ -163,6 +164,7 @@ export default function SavedLinksScreen() { await updateTitle(editingLink.id, trimmedTitle); setEditingLink(null); setTitleValue(''); + setTitleToastVisible(true); } catch (error) { Alert.alert( '제목 수정 실패', @@ -173,7 +175,12 @@ export default function SavedLinksScreen() { } }, [editingLink, isUpdatingTitle, titleValue, updateTitle]); - const titleSubmitDisabled = titleValue.trim().length === 0 || titleValue.trim().length > 500 || isUpdatingTitle; + const trimmedTitleValue = titleValue.trim(); + const titleSubmitDisabled = + trimmedTitleValue.length === 0 || + trimmedTitleValue.length > 500 || + trimmedTitleValue === editingLink?.title.trim() || + isUpdatingTitle; const showEmptyState = !isLoading && displayLinks.length === 0; return ( @@ -331,6 +338,13 @@ export default function SavedLinksScreen() { topOffset={96} onHide={() => setDeleteToastVisible(false)} /> + setTitleToastVisible(false)} + /> ); } From 739be849fea9f1eea103bde9f11641b96db367d2 Mon Sep 17 00:00:00 2001 From: KimByeongHun Date: Fri, 22 May 2026 20:13:13 +0900 Subject: [PATCH 7/8] =?UTF-8?q?refactor:=20=EC=A0=9C=EB=AA=A9=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EB=AA=A8=EB=8B=AC=20=EA=B3=B5=ED=86=B5=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/(home)/index.tsx | 206 ++------------------------ app/(tabs)/(home)/saved-links.tsx | 208 ++------------------------ components/ui/title-edit-modal.tsx | 226 +++++++++++++++++++++++++++++ 3 files changed, 247 insertions(+), 393 deletions(-) create mode 100644 components/ui/title-edit-modal.tsx diff --git a/app/(tabs)/(home)/index.tsx b/app/(tabs)/(home)/index.tsx index ea9bff7..29b792d 100644 --- a/app/(tabs)/(home)/index.tsx +++ b/app/(tabs)/(home)/index.tsx @@ -1,15 +1,9 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { Alert, - KeyboardAvoidingView, - Modal, - Platform, - Pressable, ScrollView, StyleSheet, Text, - TextInput, - TouchableOpacity, View, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; @@ -19,6 +13,7 @@ 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 { getSavedLinkErrorMessage, useSavedLinks, type SavedLink } from '@/context/saved-links-context'; @@ -31,12 +26,9 @@ export default function HomeScreen() { const { links, toggleBookmark, deleteLink, updateTitle } = useSavedLinks(); const [menuState, setMenuState] = useState<{ visible: boolean; anchor?: AnchorPosition; linkId?: number }>({ visible: false }); const [editingLink, setEditingLink] = useState(null); - const [titleValue, setTitleValue] = useState(''); - const [isUpdatingTitle, setIsUpdatingTitle] = useState(false); const [saveToastVisible, setSaveToastVisible] = useState(false); const [deleteToastVisible, setDeleteToastVisible] = useState(false); const [titleToastVisible, setTitleToastVisible] = useState(false); - const titleInputRef = useRef(null); const lastToastParamRef = useRef(undefined); const savedLinkToast = typeof savedLinkToastParam === 'string' ? savedLinkToastParam : undefined; @@ -98,49 +90,12 @@ export default function HomeScreen() { const openTitleModal = useCallback((link: SavedLink) => { setEditingLink(link); - setTitleValue(link.title); - setTimeout(() => titleInputRef.current?.focus(), 100); }, []); - const handleTitleCancel = useCallback(() => { - if (isUpdatingTitle) { - return; - } - - setEditingLink(null); - setTitleValue(''); - }, [isUpdatingTitle]); - - const handleTitleConfirm = useCallback(async () => { - const trimmedTitle = titleValue.trim(); - - if (!editingLink || trimmedTitle.length === 0 || trimmedTitle.length > 500 || isUpdatingTitle) { - return; - } - - setIsUpdatingTitle(true); - - try { - await updateTitle(editingLink.id, trimmedTitle); - setEditingLink(null); - setTitleValue(''); - setTitleToastVisible(true); - } catch (error) { - Alert.alert( - '제목 수정 실패', - getSavedLinkErrorMessage(error, '제목을 수정하지 못했습니다. 잠시 후 다시 시도해주세요.'), - ); - } finally { - setIsUpdatingTitle(false); - } - }, [editingLink, isUpdatingTitle, titleValue, updateTitle]); - - const trimmedTitleValue = titleValue.trim(); - const titleSubmitDisabled = - trimmedTitleValue.length === 0 || - trimmedTitleValue.length > 500 || - trimmedTitleValue === editingLink?.title.trim() || - isUpdatingTitle; + const handleTitleConfirm = useCallback(async (id: number, title: string) => { + await updateTitle(id, title); + setTitleToastVisible(true); + }, [updateTitle]); return ( @@ -220,64 +175,11 @@ export default function HomeScreen() { onDismiss={() => setMenuState({ visible: false })} /> - - - - - 제목 수정 - - - {titleValue.length > 0 && !isUpdatingTitle && ( - setTitleValue('')} - hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} - style={renameStyles.clearButton} - > - - - )} - - - - 취소 - - - - {isUpdatingTitle ? '저장 중...' : '저장'} - - - - - - + setEditingLink(null)} + /> (); const [menuItems, setMenuItems] = useState([]); const [editingLink, setEditingLink] = useState(null); - const [titleValue, setTitleValue] = useState(''); - const [isUpdatingTitle, setIsUpdatingTitle] = useState(false); const [deleteToastVisible, setDeleteToastVisible] = useState(false); const [titleToastVisible, setTitleToastVisible] = useState(false); - const titleInputRef = useRef(null); const displayLinks = useMemo(() => { const folderFiltered = @@ -81,8 +73,6 @@ export default function SavedLinksScreen() { const openTitleModal = useCallback((link: SavedLink) => { setEditingLink(link); - setTitleValue(link.title); - setTimeout(() => titleInputRef.current?.focus(), 100); }, []); const handleBookmark = useCallback( @@ -142,45 +132,11 @@ export default function SavedLinksScreen() { [handleDelete, openTitleModal], ); - const handleTitleCancel = useCallback(() => { - if (isUpdatingTitle) { - return; - } - - setEditingLink(null); - setTitleValue(''); - }, [isUpdatingTitle]); - - const handleTitleConfirm = useCallback(async () => { - const trimmedTitle = titleValue.trim(); - - if (!editingLink || trimmedTitle.length === 0 || trimmedTitle.length > 500 || isUpdatingTitle) { - return; - } - - setIsUpdatingTitle(true); - - try { - await updateTitle(editingLink.id, trimmedTitle); - setEditingLink(null); - setTitleValue(''); - setTitleToastVisible(true); - } catch (error) { - Alert.alert( - '제목 수정 실패', - getSavedLinkErrorMessage(error, '제목을 수정하지 못했습니다. 잠시 후 다시 시도해주세요.'), - ); - } finally { - setIsUpdatingTitle(false); - } - }, [editingLink, isUpdatingTitle, titleValue, updateTitle]); + const handleTitleConfirm = useCallback(async (id: number, title: string) => { + await updateTitle(id, title); + setTitleToastVisible(true); + }, [updateTitle]); - const trimmedTitleValue = titleValue.trim(); - const titleSubmitDisabled = - trimmedTitleValue.length === 0 || - trimmedTitleValue.length > 500 || - trimmedTitleValue === editingLink?.title.trim() || - isUpdatingTitle; const showEmptyState = !isLoading && displayLinks.length === 0; return ( @@ -271,65 +227,11 @@ export default function SavedLinksScreen() { onDismiss={() => setMenuVisible(false)} /> - {/* 링크 제목 수정 모달 */} - - - - - 제목 수정 - - - {titleValue.length > 0 && !isUpdatingTitle && ( - setTitleValue('')} - hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} - style={renameStyles.clearButton} - > - - - )} - - - - 취소 - - - - {isUpdatingTitle ? '저장 중...' : '저장'} - - - - - - + setEditingLink(null)} + /> 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, + }, +}); From d2e3956471bdb6db6c18ebb74332e46e2d7270ab Mon Sep 17 00:00:00 2001 From: KimByeongHun Date: Fri, 22 May 2026 20:15:52 +0900 Subject: [PATCH 8/8] =?UTF-8?q?refactor:=20=EC=A0=80=EC=9E=A5=20=EB=A7=81?= =?UTF-8?q?=ED=81=AC=20cursor=20=EC=BF=BC=EB=A6=AC=20=EC=A1=B0=EA=B1=B4=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/saved-links.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/saved-links.ts b/api/saved-links.ts index eaef056..91425fd 100644 --- a/api/saved-links.ts +++ b/api/saved-links.ts @@ -133,7 +133,7 @@ function buildSavedLinkQuery(query: SavedLinkListQuery) { params.set('bookmarked', String(query.bookmarked)); } - if (query.cursor) { + if (query.cursor != null) { params.set('cursor', query.cursor); }