diff --git a/app/(tabs)/(home)/add-link.tsx b/app/(tabs)/(home)/add-link.tsx new file mode 100644 index 0000000..e04efbb --- /dev/null +++ b/app/(tabs)/(home)/add-link.tsx @@ -0,0 +1,269 @@ +import { useState } from 'react'; +import { + ActivityIndicator, + KeyboardAvoidingView, + Linking, + Platform, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; +import { Stack, router } from 'expo-router'; + +import { ScanButton } from '@/components/ui/scan-button'; +import { Colors, Typography } from '@/constants/theme'; + +function isValidUrlFormat(value: string): boolean { + const trimmed = value.trim(); + if (!/^https?:\/\//i.test(trimmed)) return false; + try { + const parsed = new URL(trimmed); + const parts = parsed.hostname.split('.'); + const tld = parts[parts.length - 1]; + return parts.length >= 2 && tld.length >= 2; + } catch { + return false; + } +} + +export default function AddLinkScreen() { + const [url, setUrl] = useState(''); + const [error, setError] = useState(''); + const [isChecking, setIsChecking] = useState(false); + + const handleScan = async () => { + const trimmed = url.trim(); + + if (!trimmed) { + setError('URL을 입력해주세요.'); + return; + } + + // 1단계: new URL()로 형식 검증 + if (!isValidUrlFormat(trimmed)) { + setError('올바르지 않은 URL 입력입니다.'); + return; + } + + // 2단계: Linking.canOpenURL()로 실제 열기 가능 여부 확인 + setIsChecking(true); + try { + const canOpen = await Linking.canOpenURL(trimmed); + if (!canOpen) { + setError('올바르지 않은 URL 입력입니다.'); + return; + } + setError(''); + router.push({ pathname: '/(tabs)/(home)/scanning', params: { url: trimmed } }); + } catch { + setError('URL 확인 중 오류가 발생했습니다.'); + } finally { + setIsChecking(false); + } + }; + + const handleChangeUrl = (value: string) => { + setUrl(value); + if (error) setError(''); + }; + + const hasError = error.length > 0; + const scanDisabled = !url.trim() || isChecking; + + return ( + <> + + + + + {/* 타이틀 */} + + 링크를 + 입력해주세요 + + + {/* 부제목 */} + 보안검사 후 저장할 수 있습니다. + + {/* URL 입력 + 검사 버튼 */} + + + + {url.length > 0 && !isChecking && ( + { setUrl(''); setError(''); }} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + style={styles.clearButton} + > + + + )} + + {isChecking ? ( + + + + ) : ( + + )} + + + {/* 에러 메시지 */} + {hasError && {error}} + + {/* 안내 박스 */} + + 안내 + + 검사 후 안전한 사이트이라면, 저장하실 수 있습니다. + + + + + + + ); +} + +const styles = StyleSheet.create({ + safeArea: { + flex: 1, + backgroundColor: Colors.brand.background, + }, + flex: { + flex: 1, + }, + container: { + flex: 1, + paddingHorizontal: 24, + paddingTop: 16, + }, + + // 타이틀 + titleRow: { + flexDirection: 'row', + flexWrap: 'wrap', + marginBottom: 12, + }, + titleGreen: { + ...Typography.display, + color: Colors.brand.primaryDeep, + }, + titleDark: { + ...Typography.display, + color: Colors.brand.text, + }, + + // 부제목 + subtitle: { + ...Typography.body, + color: Colors.brand.textSecondary, + marginBottom: 32, + }, + + // 입력 행 + inputRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + marginBottom: 8, + }, + inputWrapper: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + height: 60, + borderRadius: 16, + borderWidth: 1.5, + borderColor: Colors.brand.line, + backgroundColor: Colors.light.background, + paddingHorizontal: 16, + }, + inputError: { + borderColor: Colors.brand.textWarning, + }, + input: { + flex: 1, + ...Typography.body, + color: Colors.brand.text, + }, + 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, + }, + loadingBox: { + width: 60, + height: 60, + borderRadius: 16, + borderWidth: 1.5, + borderColor: Colors.brand.line, + backgroundColor: Colors.brand.background, + alignItems: 'center', + justifyContent: 'center', + }, + + // 에러 + errorText: { + ...Typography.body, + color: Colors.brand.textWarning, + marginBottom: 16, + }, + + // 안내 박스 + infoBox: { + backgroundColor: Colors.brand.softMint, + borderRadius: 12, + padding: 16, + marginTop: 16, + gap: 6, + }, + infoLabel: { + ...Typography.caption, + color: Colors.brand.primaryDeep, + }, + infoBody: { + ...Typography.summary, + color: Colors.brand.textSecondary, + }, +}); diff --git a/app/(tabs)/(home)/index.tsx b/app/(tabs)/(home)/index.tsx index 6e257a2..003a7a1 100644 --- a/app/(tabs)/(home)/index.tsx +++ b/app/(tabs)/(home)/index.tsx @@ -1,98 +1,171 @@ -import { Image } from 'expo-image'; -import { StyleSheet } from 'react-native'; - -import { HelloWave } from '@/components/hello-wave'; -import ParallaxScrollView from '@/components/parallax-scroll-view'; -import { ThemedText } from '@/components/themed-text'; -import { ThemedView } from '@/components/themed-view'; -import { Link } from 'expo-router'; +import { useState } from 'react'; +import { ScrollView, 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'; +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 { Colors, Typography } from '@/constants/theme'; +import { useSavedLinks } from '@/context/saved-links-context'; +import type { AnchorPosition } from '@/components/ui/folder-card'; export default function HomeScreen() { - const devShortcut = - process.env.EXPO_OS === 'ios' - ? 'cmd + d' - : process.env.EXPO_OS === 'android' - ? 'cmd + m' - : 'F12'; + const { links, toggleBookmark, deleteLink } = useSavedLinks(); + const [menuState, setMenuState] = useState<{ visible: boolean; anchor?: AnchorPosition; linkId?: number }>({ visible: false }); + + // 최근 저장한 링크 — createdAt 내림차순 상위 3개 + const recentLinks = links.slice(0, 3); + + const handleMore = (id: number, anchor: AnchorPosition) => { + setMenuState({ visible: true, anchor, linkId: id }); + }; + + const selectedLink = links.find((l) => l.id === menuState.linkId); return ( - - }> - - Welcome! - - - - Step 1: Try it - - Edit app/(tabs)/index.tsx to see changes. - Press{' '} - {devShortcut} - {' '}to open developer tools. - - - - - - Step 2: Explore - - - - alert('Action pressed')} /> - alert('Share pressed')} + + + {/* 상단 헤더 */} + + + - - alert('Delete pressed')} + LinClean + + router.push('/(tabs)/(home)/settings')} /> + + + {/* 서브타이틀 */} + 오늘도 안전하게 정리해요 + + {/* 보안 등급별 링크 현황 */} + + + + + + + + + + + + + {/* 최근 저장한 링크 */} + + router.push('/saved-links')} + /> + + {recentLinks.map((link) => ( + toggleBookmark(link.id)} + onMore={(anchor) => handleMore(link.id, anchor)} /> - - - - - {`Tap the Explore tab to learn more about what's included in this starter app.`} - - - - Step 3: Get a fresh start - - {`When you're ready, run `} - npm run reset-project to get a fresh{' '} - app directory. This will move the current{' '} - app to{' '} - app-example. - - - + ))} + + + + + menuState.linkId != null && toggleBookmark(menuState.linkId), + }, + { + label: '링크 삭제', + onPress: () => menuState.linkId != null && deleteLink(menuState.linkId), + destructive: true, + }, + ]} + onDismiss={() => setMenuState({ visible: false })} + /> + ); } const styles = StyleSheet.create({ - titleContainer: { + safeArea: { + flex: 1, + backgroundColor: Colors.brand.background, + }, + scroll: { + flex: 1, + }, + content: { + paddingHorizontal: 25, + paddingBottom: 32, + gap: 24, + }, + + header: { flexDirection: 'row', alignItems: 'center', - gap: 8, + justifyContent: 'space-between', + paddingTop: 16, }, - stepContainer: { - gap: 8, - marginBottom: 8, + wordmark: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + }, + brandText: { + ...Typography.title, + color: Colors.brand.primary, + }, + + subtitle: { + ...Typography.caption, + color: Colors.brand.textSecondary, + marginTop: -16, + }, + + section: { + gap: 12, }, - reactLogo: { - height: 178, - width: 290, - bottom: 0, - left: 0, - position: 'absolute', + + statPlaceholder: { + backgroundColor: '#fff', + borderRadius: 16, + borderWidth: 1, + borderColor: Colors.brand.line, + padding: 16, + }, + statGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 12, + }, + statItem: { + flex: 1, + minWidth: '45%', + height: 56, + borderRadius: 10, + borderWidth: 1.5, + borderColor: Colors.brand.line, + borderStyle: 'dashed', + }, + + linkList: { + gap: 12, }, }); diff --git a/app/(tabs)/(home)/saved-links.tsx b/app/(tabs)/(home)/saved-links.tsx new file mode 100644 index 0000000..e4b556e --- /dev/null +++ b/app/(tabs)/(home)/saved-links.tsx @@ -0,0 +1,153 @@ +import { useState, useMemo, useCallback } from 'react'; +import { FlatList, 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'; +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 type { AnchorPosition } from '@/components/ui/folder-card'; +import { Colors, Typography } from '@/constants/theme'; +import { useSavedLinks, type SavedLink } from '@/context/saved-links-context'; + +// ─── 폴더 필터 칩 아이템 ────────────────────────────────────────────────────── +// 실제 구현 시 GET /api/v1/categories 응답으로 교체 + +// 폴더명은 folder/index.tsx MOCK_FOLDERS와 동일하게 유지 (API 연동 시 GET /categories로 교체) +const FOLDER_ITEMS: FilterChipItem[] = [ + { label: '전체', value: 'all' }, + { label: '맛집 정보', value: '1' }, + { label: '취업', value: '2' }, + { label: '취미', value: '3' }, +]; + +// ─── Screen ─────────────────────────────────────────────────────────────────── + +export default function SavedLinksScreen() { + const { links, toggleBookmark, deleteLink } = useSavedLinks(); + + const [selectedFolder, setSelectedFolder] = useState('all'); + const [bookmarkFilter, setBookmarkFilter] = useState(false); + + const [menuVisible, setMenuVisible] = useState(false); + const [menuAnchor, setMenuAnchor] = useState(); + const [menuItems, setMenuItems] = useState([]); + + const displayLinks = useMemo(() => { + const folderFiltered = + selectedFolder === 'all' + ? links + : links.filter((link) => String(link.categoryId) === selectedFolder); + + if (!bookmarkFilter) return folderFiltered; + + return [...folderFiltered].sort((a, b) => Number(b.isBookmarked) - Number(a.isBookmarked)); + }, [links, selectedFolder, bookmarkFilter]); + + const openMoreMenu = useCallback( + (link: SavedLink, anchor: AnchorPosition) => { + setMenuAnchor(anchor); + setMenuItems([ + { + label: link.isBookmarked ? '북마크 제거' : '북마크 추가', + onPress: () => toggleBookmark(link.id), + }, + { + label: '링크 삭제', + onPress: () => deleteLink(link.id), + destructive: true, + }, + ]); + setMenuVisible(true); + }, + [toggleBookmark, deleteLink] + ); + + return ( + + {/* 헤더 */} + + router.back()} /> + 저장한 링크 + + + {/* 필터 칩 */} + + + setBookmarkFilter((prev) => !prev)} + /> + + + {/* 링크 목록 */} + String(item.id)} + contentContainerStyle={styles.listContent} + showsVerticalScrollIndicator={false} + renderItem={({ item }) => ( + toggleBookmark(item.id)} + onMore={(anchor) => openMoreMenu(item, anchor)} + /> + )} + ItemSeparatorComponent={() => } + /> + + {/* 링크 컨텍스트 메뉴 */} + setMenuVisible(false)} + /> + + ); +} + +const styles = StyleSheet.create({ + safeArea: { + flex: 1, + backgroundColor: Colors.brand.background, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + paddingHorizontal: 20, + paddingTop: 12, + paddingBottom: 8, + }, + title: { + ...Typography.title, + color: Colors.brand.text, + }, + chipRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + paddingHorizontal: 20, + paddingVertical: 12, + zIndex: 10, + }, + listContent: { + paddingHorizontal: 20, + paddingTop: 4, + paddingBottom: 32, + }, + separator: { + height: 12, + }, +}); diff --git a/app/(tabs)/(home)/scan-result-block.tsx b/app/(tabs)/(home)/scan-result-block.tsx new file mode 100644 index 0000000..ec306bb --- /dev/null +++ b/app/(tabs)/(home)/scan-result-block.tsx @@ -0,0 +1,126 @@ +import { router, Stack, useLocalSearchParams } from 'expo-router'; +import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { Colors, Typography } from '@/constants/theme'; +import { ResultStatusIcon } from '@/components/ui/result-status-icon'; + +export default function ScanResultBlockScreen() { + const { url } = useLocalSearchParams<{ url: string }>(); + + return ( + <> + + + {/* 차단 배지 */} + + + + + {/* 결과 텍스트 */} + 차단된 위험 링크입니다. + + {'고위험 신호가 감지되었습니다.\n해당 링크는 저장할 수 없습니다.'} + + + {/* 검사 대상 카드 */} + + 검사 대상 + + {url} + + + + {/* 확인 버튼 */} + + router.dismissAll()} + activeOpacity={0.8} + > + 확인 + + + + + ); +} + +const styles = StyleSheet.create({ + scroll: { + flex: 1, + backgroundColor: Colors.brand.background, + }, + container: { + flexGrow: 1, + paddingHorizontal: 24, + paddingTop: 40, + paddingBottom: 40, + alignItems: 'center', + }, + + badgeArea: { + alignItems: 'center', + marginBottom: 32, + }, + + resultTitle: { + ...Typography.display, + color: Colors.brand.text, + textAlign: 'center', + marginBottom: 12, + }, + resultSubtitle: { + ...Typography.body, + color: Colors.brand.textSecondary, + textAlign: 'center', + lineHeight: 24, + marginBottom: 40, + }, + + card: { + width: '100%', + backgroundColor: Colors.light.background, + borderRadius: 16, + padding: 16, + gap: 6, + marginBottom: 40, + }, + cardLabel: { + ...Typography.caption, + color: Colors.brand.textHint, + }, + cardUrl: { + ...Typography.body, + fontWeight: '700', + color: Colors.brand.text, + }, + + buttonArea: { + width: '100%', + }, + confirmButton: { + width: '100%', + height: 56, + borderRadius: 28, + backgroundColor: Colors.brand.textWarning, + alignItems: 'center', + justifyContent: 'center', + }, + confirmButtonText: { + ...Typography.section, + color: '#FFFFFF', + }, +}); diff --git a/app/(tabs)/(home)/scan-result-caution.tsx b/app/(tabs)/(home)/scan-result-caution.tsx new file mode 100644 index 0000000..971c24b --- /dev/null +++ b/app/(tabs)/(home)/scan-result-caution.tsx @@ -0,0 +1,167 @@ +import { router, Stack, useLocalSearchParams } from 'expo-router'; +import { Linking, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { Colors, Typography } from '@/constants/theme'; +import { ResultStatusIcon } from '@/components/ui/result-status-icon'; +import { useSavedLinks } from '@/context/saved-links-context'; + +export default function ScanResultCautionScreen() { + const { url } = useLocalSearchParams<{ url: string }>(); + const { addLink } = useSavedLinks(); + + const handleSave = () => { + // TODO: POST /api/v1/saved-links { analysisId } 호출 후 응답으로 교체 + addLink({ + id: Date.now(), + analysisId: `mock-${Date.now()}`, + categoryId: null, + originalUrl: url ?? '', + finalUrl: url ?? null, + title: url ?? '제목 없음', + description: '저장된 링크입니다.', + siteName: (() => { + try { return new URL(url ?? '').hostname; } catch { return '알 수 없음'; } + })(), + verdict: 'caution', + isBookmarked: false, + createdAt: new Date().toISOString(), + }); + router.dismissAll(); + }; + + const handleOpenUrl = () => { + if (url) Linking.openURL(url); + }; + + return ( + <> + + + {/* 주의 배지 */} + + + + + {/* 결과 텍스트 */} + 주의가 필요한 링크입니다. + + {'의심 신호가 일부 감지됐어요.\n계속 진행할지 한번 더 확인하세요.'} + + + {/* 검사 대상 카드 */} + + 검사 대상 + + {url} + + + + {/* 버튼 영역 */} + + + 주의 후 저장 + + + + 즉시 URL 접속 + + + + + ); +} + +const styles = StyleSheet.create({ + scroll: { + flex: 1, + backgroundColor: Colors.brand.background, + }, + container: { + flexGrow: 1, + paddingHorizontal: 24, + paddingTop: 40, + paddingBottom: 40, + alignItems: 'center', + }, + + badgeArea: { + alignItems: 'center', + marginBottom: 32, + }, + + resultTitle: { + ...Typography.display, + color: Colors.brand.text, + textAlign: 'center', + marginBottom: 12, + }, + resultSubtitle: { + ...Typography.body, + color: Colors.brand.textSecondary, + textAlign: 'center', + lineHeight: 24, + marginBottom: 40, + }, + + card: { + width: '100%', + backgroundColor: Colors.light.background, + borderRadius: 16, + padding: 16, + gap: 6, + marginBottom: 40, + }, + cardLabel: { + ...Typography.caption, + color: Colors.brand.textHint, + }, + cardUrl: { + ...Typography.body, + fontWeight: '700', + color: Colors.brand.text, + }, + + buttonArea: { + width: '100%', + gap: 12, + }, + cautionButton: { + width: '100%', + height: 56, + borderRadius: 28, + backgroundColor: Colors.brand.textCaution, + alignItems: 'center', + justifyContent: 'center', + }, + cautionButtonText: { + ...Typography.section, + color: Colors.brand.text, + }, + secondaryButton: { + width: '100%', + height: 56, + borderRadius: 28, + backgroundColor: Colors.light.background, + borderWidth: 1.5, + borderColor: Colors.brand.line, + alignItems: 'center', + justifyContent: 'center', + }, + secondaryButtonText: { + ...Typography.section, + color: Colors.brand.text, + }, +}); diff --git a/app/(tabs)/(home)/scan-result.tsx b/app/(tabs)/(home)/scan-result.tsx new file mode 100644 index 0000000..d727477 --- /dev/null +++ b/app/(tabs)/(home)/scan-result.tsx @@ -0,0 +1,217 @@ +import { Ionicons } from '@expo/vector-icons'; +import { router, Stack, useLocalSearchParams } from 'expo-router'; +import { Linking, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { Colors, Typography } from '@/constants/theme'; +import { useSavedLinks } from '@/context/saved-links-context'; + +// 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 { url } = useLocalSearchParams<{ url: string }>(); + const { addLink } = useSavedLinks(); + + const handleSave = () => { + // TODO: POST /api/v1/saved-links { analysisId } 호출 후 응답으로 교체 + // 현재는 URL 기반 mock 데이터로 즉시 추가 + addLink({ + id: Date.now(), + analysisId: `mock-${Date.now()}`, + categoryId: null, + originalUrl: url ?? '', + finalUrl: url ?? null, + title: url ?? '제목 없음', + description: '저장된 링크입니다.', + siteName: (() => { + try { return new URL(url ?? '').hostname; } catch { return '알 수 없음'; } + })(), + verdict: 'safe', + isBookmarked: false, + createdAt: new Date().toISOString(), + }); + router.dismissAll(); + }; + + const handleOpenUrl = () => { + if (url) { + Linking.openURL(url); + } + }; + + return ( + <> + + + {/* 안전 배지 영역 */} + + + + + + + + {/* 안전 칩 */} + + 안전 + + + + {/* 결과 텍스트 */} + 안전한 웹사이트입니다. + 저장 후 바로 접속하거나,{'\n'}즉시 URL로 이동할 수 있어요. + + {/* 검사 대상 카드 */} + + 검사 대상 + + {url} + + + + {/* 버튼 영역 */} + + + 저장 + + + + 즉시 URL 접속 + + + + + ); +} + +const styles = StyleSheet.create({ + scroll: { + flex: 1, + backgroundColor: Colors.brand.background, + }, + container: { + flexGrow: 1, + paddingHorizontal: 24, + paddingTop: 40, + paddingBottom: 40, + alignItems: 'center', + }, + + // 배지 영역 + badgeArea: { + alignItems: 'center', + marginBottom: 32, + }, + orbOuter: { + width: 160, + height: 160, + borderRadius: 80, + backgroundColor: Colors.brand.softMint, + alignItems: 'center', + justifyContent: 'center', + marginBottom: 16, + }, + orbInner: { + width: 100, + height: 100, + borderRadius: 50, + backgroundColor: Colors.brand.background, + alignItems: 'center', + justifyContent: 'center', + }, + chip: { + backgroundColor: Colors.light.background, + borderRadius: 20, + paddingVertical: 6, + paddingHorizontal: 20, + borderWidth: 1, + borderColor: Colors.brand.line, + }, + chipText: { + ...Typography.summary, + color: Colors.brand.text, + }, + + // 결과 텍스트 + resultTitle: { + ...Typography.display, + color: Colors.brand.text, + textAlign: 'center', + marginBottom: 12, + }, + resultSubtitle: { + ...Typography.body, + color: Colors.brand.textSecondary, + textAlign: 'center', + lineHeight: 24, + marginBottom: 40, + }, + + // 검사 대상 카드 + card: { + width: '100%', + backgroundColor: Colors.light.background, + borderRadius: 16, + padding: 16, + gap: 6, + marginBottom: 40, + }, + cardLabel: { + ...Typography.caption, + color: Colors.brand.textHint, + }, + cardUrl: { + ...Typography.body, + fontWeight: '700', + color: Colors.brand.text, + }, + + // 버튼 영역 + buttonArea: { + width: '100%', + gap: 12, + }, + primaryButton: { + width: '100%', + height: 56, + borderRadius: 28, + backgroundColor: Colors.brand.primary, + alignItems: 'center', + justifyContent: 'center', + }, + primaryButtonText: { + ...Typography.section, + color: Colors.light.background, + }, + secondaryButton: { + width: '100%', + height: 56, + borderRadius: 28, + backgroundColor: Colors.light.background, + borderWidth: 1.5, + borderColor: Colors.brand.line, + alignItems: 'center', + justifyContent: 'center', + }, + secondaryButtonText: { + ...Typography.section, + color: Colors.brand.text, + }, +}); diff --git a/app/(tabs)/(home)/scanning.tsx b/app/(tabs)/(home)/scanning.tsx new file mode 100644 index 0000000..de634de --- /dev/null +++ b/app/(tabs)/(home)/scanning.tsx @@ -0,0 +1,133 @@ +import LottieView from 'lottie-react-native'; +import { router, Stack, useLocalSearchParams } from 'expo-router'; +import { useEffect } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { Colors, Typography } from '@/constants/theme'; + +// TODO: 백엔드 연동 시 POST /api/v1/analyses 호출 후 폴링으로 결과 확인 +// Request: { original_url } +// Response: { analysis_id, status, verdict, score, summary } +// verdict: 'safe' → scan-result(allowed), 'caution'/'danger' → 별도 결과 화면 +// API 명세: Draft of the specification.md > POST /analyses, GET /analyses/{analysisId} 참고 + +export default function ScanningScreen() { + const { url } = useLocalSearchParams<{ url: string }>(); + + useEffect(() => { + // TODO: 실제 백엔드 분석 요청으로 교체 + // verdict에 따라 화면 분기: + // safe → '/(tabs)/(home)/scan-result' + // caution → '/(tabs)/(home)/scan-result-caution' + // block → '/(tabs)/(home)/scan-result-block' + const timer = setTimeout(() => { + router.replace({ pathname: '/(tabs)/(home)/scan-result', params: { url } }); + }, 3000); + return () => clearTimeout(timer); + }, [url]); + + return ( + <> + + + {/* Lottie 애니메이션 + 가운데 점 */} + + + + + + + + + + {/* 텍스트 */} + 보안 검사 중입니다 + 약 5–10초 정도 소요돼요 + + {/* 검사 대상 카드 */} + + 검사 대상 + + {url} + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: Colors.brand.background, + alignItems: 'center', + paddingHorizontal: 24, + paddingTop: 40, + }, + animationWrapper: { + width: 280, + height: 280, + alignItems: 'center', + justifyContent: 'center', + marginBottom: 32, + }, + animation: { + width: 280, + height: 280, + position: 'absolute', + }, + dotsOverlay: { + flexDirection: 'row', + gap: 8, + alignItems: 'center', + justifyContent: 'center', + }, + dot: { + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: Colors.brand.textHint, + }, + title: { + ...Typography.display, + color: Colors.brand.text, + textAlign: 'center', + marginBottom: 8, + }, + subtitle: { + ...Typography.body, + color: Colors.brand.textSecondary, + textAlign: 'center', + marginBottom: 40, + }, + card: { + width: '100%', + backgroundColor: Colors.light.background, + borderRadius: 16, + padding: 16, + gap: 6, + }, + cardLabel: { + ...Typography.caption, + color: Colors.brand.textHint, + }, + cardUrl: { + ...Typography.body, + fontWeight: '700', + color: Colors.brand.text, + }, +}); diff --git a/assets/animations/scanning.json b/assets/animations/scanning.json new file mode 100644 index 0000000..f25be22 --- /dev/null +++ b/assets/animations/scanning.json @@ -0,0 +1,150 @@ +{ + "v": "5.7.4", + "fr": 60, + "ip": 0, + "op": 120, + "w": 300, + "h": 300, + "nm": "scanning", + "ddd": 0, + "assets": [], + "layers": [ + { + "ddd": 0, + "ind": 1, + "ty": 4, + "nm": "Spinning Arc", + "sr": 1, + "ks": { + "o": { "a": 0, "k": 100 }, + "r": { + "a": 1, + "k": [ + { "i": { "x": [0.833], "y": [0.833] }, "o": { "x": [0.167], "y": [0.167] }, "t": 0, "s": [0] }, + { "t": 120, "s": [360] } + ] + }, + "p": { "a": 0, "k": [150, 150, 0] }, + "a": { "a": 0, "k": [0, 0, 0] }, + "s": { "a": 0, "k": [100, 100, 100] } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "nm": "Arc Group", + "it": [ + { + "ty": "el", + "nm": "Ellipse Path", + "p": { "a": 0, "k": [0, 0] }, + "s": { "a": 0, "k": [220, 220] } + }, + { + "ty": "tm", + "nm": "Trim Paths", + "s": { "a": 0, "k": 0 }, + "e": { "a": 0, "k": 75 }, + "o": { "a": 0, "k": 0 }, + "m": 1 + }, + { + "ty": "st", + "nm": "Stroke", + "c": { "a": 0, "k": [0.443, 0.659, 0.588, 1] }, + "o": { "a": 0, "k": 100 }, + "w": { "a": 0, "k": 14 }, + "lc": 2, + "lj": 2, + "ml": 4 + } + ] + } + ], + "ip": 0, + "op": 120, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 2, + "ty": 4, + "nm": "White Inner Circle", + "sr": 1, + "ks": { + "o": { "a": 0, "k": 100 }, + "r": { "a": 0, "k": 0 }, + "p": { "a": 0, "k": [150, 150, 0] }, + "a": { "a": 0, "k": [0, 0, 0] }, + "s": { "a": 0, "k": [100, 100, 100] } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "nm": "Inner Circle", + "it": [ + { + "ty": "el", + "nm": "Ellipse", + "p": { "a": 0, "k": [0, 0] }, + "s": { "a": 0, "k": [190, 190] } + }, + { + "ty": "fl", + "nm": "Fill", + "c": { "a": 0, "k": [1, 1, 1, 1] }, + "o": { "a": 0, "k": 100 }, + "r": 1 + } + ] + } + ], + "ip": 0, + "op": 120, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 3, + "ty": 4, + "nm": "Gray Outer Circle", + "sr": 1, + "ks": { + "o": { "a": 0, "k": 100 }, + "r": { "a": 0, "k": 0 }, + "p": { "a": 0, "k": [150, 150, 0] }, + "a": { "a": 0, "k": [0, 0, 0] }, + "s": { "a": 0, "k": [100, 100, 100] } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "nm": "Outer Circle", + "it": [ + { + "ty": "el", + "nm": "Ellipse", + "p": { "a": 0, "k": [0, 0] }, + "s": { "a": 0, "k": [262, 262] } + }, + { + "ty": "fl", + "nm": "Fill", + "c": { "a": 0, "k": [0.867, 0.914, 0.894, 1] }, + "o": { "a": 0, "k": 100 }, + "r": 1 + } + ] + } + ], + "ip": 0, + "op": 120, + "st": 0, + "bm": 0 + } + ] +} diff --git a/context/saved-links-context.tsx b/context/saved-links-context.tsx new file mode 100644 index 0000000..29644c2 --- /dev/null +++ b/context/saved-links-context.tsx @@ -0,0 +1,184 @@ +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; + 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) => void; +} + +// ─── 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 addLink = useCallback((link: SavedLink) => { + // TODO: 백엔드 연동 시 POST /api/v1/saved-links { analysisId } 호출 후 응답으로 교체 + setLinks((prev) => [link, ...prev]); + }, []); + + const toggleBookmark = useCallback((id: number) => { + // 실제 구현 시 PATCH /api/v1/saved-links/{id}/bookmark 호출 + setLinks((prev) => + prev.map((link) => + link.id === id ? { ...link, isBookmarked: !link.isBookmarked } : link + ) + ); + }, []); + + const deleteLink = useCallback((id: number) => { + // 실제 구현 시 DELETE /api/v1/saved-links/{id} 호출 + setLinks((prev) => prev.filter((link) => link.id !== id)); + }, []); + + const assignCategory = useCallback((ids: number[], categoryId: number) => { + // 실제 구현 시 ids.forEach → PATCH /api/v1/saved-links/{id} { categoryId } 호출 + setLinks((prev) => + prev.map((link) => + ids.includes(link.id) ? { ...link, categoryId } : link + ) + ); + }, []); + + return ( + + {children} + + ); +} + +export function useSavedLinks(): SavedLinksContextValue { + const ctx = useContext(SavedLinksContext); + if (!ctx) throw new Error('useSavedLinks must be used within SavedLinksProvider'); + return ctx; +}