diff --git a/app.json b/app.json index 8697025..db5f48a 100644 --- a/app.json +++ b/app.json @@ -4,13 +4,15 @@ "slug": "oba_frontend", "version": "1.0.0", "orientation": "portrait", - "platforms": ["ios", "android", "web"], + "platforms": [ + "ios", + "android", + "web" + ], "scheme": "myapp", - "ios": { "supportsTablet": true }, - "android": { "adaptiveIcon": { "backgroundColor": "#ffffff", @@ -19,12 +21,10 @@ "edgeToEdgeEnabled": true, "predictiveBackGestureEnabled": false }, - "web": { "output": "static", "favicon": "./assets/images/favicon.png" }, - "plugins": [ "expo-router", [ @@ -38,9 +38,10 @@ "backgroundColor": "#000000" } } - ] + ], + "expo-web-browser", + "expo-secure-store" ], - "experiments": { "typedRoutes": true, "reactCompiler": true diff --git a/app/(auth)/_layout.tsx b/app/(auth)/_layout.tsx index dae8cfe..2191bce 100644 --- a/app/(auth)/_layout.tsx +++ b/app/(auth)/_layout.tsx @@ -1,11 +1,13 @@ +// oba_frontend/app/(auth)/_layout.tsx import { Stack } from "expo-router"; -import AppBackground from "../components/AppBackground"; // 경로 확인 필요 +// ✅ 여기는 한 단계 위로 올라가야 하므로 "../" 가 맞습니다. +import AppBackground from "../components/AppBackground"; export default function AuthLayout() { return ( <> - + ); } \ No newline at end of file diff --git a/app/(auth)/login.tsx b/app/(auth)/login.tsx index d5743f7..7d704ce 100644 --- a/app/(auth)/login.tsx +++ b/app/(auth)/login.tsx @@ -1,33 +1,87 @@ -import { View, Text, TouchableOpacity, Image, StyleSheet, useWindowDimensions } from "react-native"; +// oba_frontend/app/(auth)/login.tsx + +import { View, Text, TouchableOpacity, Image, StyleSheet, useWindowDimensions, Alert } from "react-native"; +import { useRouter } from "expo-router"; +import * as WebBrowser from "expo-web-browser"; +import * as Linking from "expo-linking"; +import * as SecureStore from "expo-secure-store"; + +WebBrowser.maybeCompleteAuthSession(); export default function Login() { const { width, height } = useWindowDimensions(); + const router = useRouter(); + + const BACKEND_URL = "http://dev.onebitearticle.com:9000"; + const REDIRECT_URI = "exp://192.168.219.101:8081/--/oauth/callback"; + + const handleLogin = async (provider: string) => { + try { + const authUrl = `${BACKEND_URL}/oauth2/authorization/${provider.toLowerCase()}`; + console.log(`📡 [Login] 시작: ${authUrl}`); + + // 브라우저 열기 + const result = await WebBrowser.openAuthSessionAsync(authUrl, REDIRECT_URI); + + if (result.type === "success" && result.url) { + console.log("🔗 [WebBrowser] 결과 URL:", result.url); + + const { queryParams } = Linking.parse(result.url); + + // 🚨 [수정 완료] 로그에 찍힌 이름 그대로 사용! + const accessToken = queryParams?.access_token; + const refreshToken = queryParams?.refresh_token; + + if (accessToken) { + console.log("✅ [Login] 토큰 획득 성공!"); + + // 문자열인지 확인 후 저장 (배열일 경우 첫 번째 요소 사용) + const accessStr = Array.isArray(accessToken) ? accessToken[0] : accessToken; + const refreshStr = Array.isArray(refreshToken) ? refreshToken[0] : refreshToken; + + await SecureStore.setItemAsync("accessToken", accessStr); + if (refreshStr) { + await SecureStore.setItemAsync("refreshToken", refreshStr); + } + + Alert.alert("환영합니다!", "성공적으로 로그인되었습니다.", [ + { text: "시작하기", onPress: () => router.replace("/(tabs)") }, + ]); + } else { + console.log("⚠️ 토큰이 없습니다. 파라미터:", queryParams); + } + } else { + console.log("❌ [Login] 취소됨/실패:", result.type); + } + + } catch (error) { + console.error("❌ 로그인 에러:", error); + Alert.alert("오류", "로그인 중 문제가 발생했습니다."); + } + }; + return ( - {/* 로고 / 캐릭터 */} - - {/* 타이틀 */} 한입기사 One Bite Article - {/* 소셜 로그인 버튼 영역 */} - + handleLogin("google")}> 구글로 로그인 - + handleLogin("kakao")}> 카카오로 로그인 - + handleLogin("naver")}> 네이버로 로그인 @@ -37,24 +91,11 @@ export default function Login() { } const styles = StyleSheet.create({ - container: { - flex: 1, - justifyContent: "center", - alignItems: "center", - paddingHorizontal: 16, - }, + container: { flex: 1, justifyContent: "center", alignItems: "center", paddingHorizontal: 16 }, title: { fontSize: 32, fontWeight: "800", color: "#333" }, subtitle: { fontSize: 16, color: "#666", marginTop: 4, marginBottom: 42 }, btnWrap: { width: "85%", gap: 14, alignItems: "center" }, - btn: { - flexDirection: "row", - alignItems: "center", - paddingVertical: 14, - borderRadius: 12, - gap: 10, - justifyContent: "center", - width: "100%", - }, + btn: { flexDirection: "row", alignItems: "center", paddingVertical: 14, borderRadius: 12, gap: 10, justifyContent: "center", width: "100%" }, icon: { width: 20, height: 20, resizeMode: "contain" }, google: { backgroundColor: "#FFF", borderWidth: 1, borderColor: "#DDD" }, kakao: { backgroundColor: "#FEE500" }, diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 3d236a2..cda97e5 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -1,44 +1,20 @@ -// oba_frontend/app/_layout.tsx -import { DarkTheme, DefaultTheme, ThemeProvider } from "@react-navigation/native"; -import { Stack } from "expo-router"; -import { StatusBar } from "expo-status-bar"; -import { View } from "react-native"; -import "react-native-reanimated"; -import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context"; +// oba_frontend/app/(tabs)/_layout.tsx -// ✅ 수정됨: 올바른 경로로 변경 (./article/components -> ./components) -import AppBackground from "../components/AppBackground"; -const MyTheme = { - ...DefaultTheme, - colors: { - ...DefaultTheme.colors, - background: "transparent", - }, -}; +import { Tabs } from "expo-router"; +import React from "react"; -export default function RootLayout() { +export default function TabLayout() { return ( - - - - - {/* 배경 컴포넌트 적용 */} - - - - - {/* 탭 화면 */} - - {/* 로그인(인증) 화면 */} - - {/* 기사 상세 화면 */} - - - - - - - - + + + + + + ); } \ No newline at end of file diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 9366772..959c463 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,7 +1,5 @@ -// oba_fronted/app/(tabs)/index.tsx -// oba_fronted/app/(tabs)/index.tsx - -import { useRef, useState, useEffect } from "react"; +//oba_frontend//app/(tabs)/index.tsx +import { useRef, useState, useCallback } from "react"; import { View, Text, @@ -10,28 +8,34 @@ import { useWindowDimensions, Pressable, ActivityIndicator, + StyleSheet, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { Link } from "expo-router"; +import { Link, useFocusEffect } from "expo-router"; // navigation 제거 + import { apiClient } from "../../src/api/apiClient"; import PizzaMenu from "../components/PizzaMenu"; +import React from "react"; + +interface UserSummary { + name: string; + picture?: string; + consecutiveDays: number; + weeklyLog: boolean[]; +} -// ✅ [수정 1] MongoDB ID는 문자열이므로 string으로 변경 interface ArticleSummary { - articleId: string; - article_id?: string; + articleId: string; title: string; summaryBullets?: string[]; - summary_bullets?: string[]; servingDate?: string; - serving_date?: string; } export default function Home() { const insets = useSafeAreaInsets(); const { width, height } = useWindowDimensions(); - const scrollX = useRef(new Animated.Value(0)).current; + const scrollX = useRef(new Animated.Value(0)).current; const CARD_WIDTH = width * 0.65; const CARD_HEIGHT = Math.min(height * 0.5, 500); @@ -39,165 +43,268 @@ export default function Home() { const SNAP_INTERVAL = CARD_WIDTH + 10; const [articles, setArticles] = useState([]); + const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); - useEffect(() => { - const fetchArticles = async () => { + useFocusEffect( + useCallback(() => { + // 🚨 [삭제됨] navigation.setOptions 코드를 지워서 탭 바가 다시 켜지지 않게 함 + fetchData(); + }, []) + ); + + const fetchData = async () => { + try { try { - console.log("📡 [Home] 기사 데이터 요청 시작..."); - - // 1. 최신 기사 가져오기 - const res = await apiClient.get("/articles/latest?limit=10"); - - // ✅ [디버깅] 서버에서 실제로 어떤 데이터가 오는지 로그로 확인 - console.log("📥 [Home] 서버 응답 데이터:", JSON.stringify(res.data, null, 2)); - - const data = res.data; - - // 2. 오늘 날짜 계산 - const now = new Date(); - const year = now.getFullYear(); - const month = String(now.getMonth() + 1).padStart(2, '0'); - const day = String(now.getDate()).padStart(2, '0'); - const today = `${year}-${month}-${day}`; - - console.log(`📅 [Home] 앱 기준 오늘 날짜: ${today}`); - - // ✅ [수정 2] 필터링 로직 완화 (일단 모든 데이터를 보여주도록 수정) - // 만약 서버 데이터가 없으면 빈 배열 - if (!data || !Array.isArray(data)) { - console.log("⚠️ 데이터가 배열이 아닙니다."); - setArticles([]); - return; + const userRes = await apiClient.get("/api/users/me"); + if (userRes.data && userRes.data.name) { + setUser(userRes.data); + } else { + setUser(null); } + } catch (e) { + setUser(null); + } - const mappedArticles = data.map(item => ({ - // 컴포넌트에서 쓰기 편하게 통일 - // 서버 응답이 articleId 인지 _id 인지 확인 필요 (Mongo는 보통 _id) - articleId: item.articleId || item.article_id || (item as any)._id || "", - title: item.title, - summaryBullets: item.summaryBullets || item.summary_bullets || [], - servingDate: item.servingDate || item.serving_date || "", - })); - - // 🚀 필터링을 잠시 끄고 데이터를 전부 보여줍니다. - // 나중에 servingDate 형식이 확인되면 다시 필터를 켜세요. - console.log(`✅ [Home] 표시할 기사 개수: ${mappedArticles.length}개`); - setArticles(mappedArticles); - - } catch (err) { - console.error("❌ [Home] 기사 로딩 실패:", err); - setArticles([]); - } finally { - setLoading(false); + const res = await apiClient.get("/api/articles/latest?limit=10"); + const data = res.data; + + if (!data || !Array.isArray(data)) { + setArticles([]); + } else { + const mappedArticles = data.map(item => ({ + articleId: (item as any).articleId || (item as any).id || (item as any)._id || "", + title: item.title || "제목 없음", + summaryBullets: (item as any).summaryBullets || (item as any).summary_bullets || [], + servingDate: (item as any).servingDate || (item as any).serving_date || "", + })); + setArticles(mappedArticles); } - }; + } catch (err) { + console.error("데이터 로딩 실패:", err); + } finally { + setLoading(false); + } + }; - fetchArticles(); - }, []); + const getFormattedDate = () => { + const now = new Date(); + const days = ['일', '월', '화', '수', '목', '금', '토']; + return `${now.getFullYear()}. ${String(now.getMonth() + 1).padStart(2, '0')}. ${String(now.getDate()).padStart(2, '0')}. (${days[now.getDay()]})`; + }; if (loading) { return ( - - - 따끈한 피자 기사를 굽는 중... 🍕 + + ); } return ( - - - - 오늘의 기사 - + + - {articles.length === 0 ? ( - - - 아직 도착한 기사가 없어요. - ({new Date().toLocaleDateString()} 기준) - - ) : ( - - {articles.map((item, i) => { - const inputRange = [ - (i - 1) * SNAP_INTERVAL, - i * SNAP_INTERVAL, - (i + 1) * SNAP_INTERVAL, - ]; - - const scale = scrollX.interpolate({ - inputRange, - outputRange: [0.9, 1, 0.9], - extrapolate: "clamp", - }); - - const summaryText = item.summaryBullets && item.summaryBullets.length > 0 - ? item.summaryBullets.map(s => `• ${s}`).join("\n") - : "요약 내용이 없습니다."; - - return ( - - - - - {item.title} + + + + + + {user ? `${user.name}님` : "로그인이 필요해요"} + + + {getFormattedDate()} + + + {user ? ( + + 🔥 + + 연속 학습 {user.consecutiveDays}일 + + + ) : ( + + + + 로그인 하러가기 {'>'} - - - + + + )} + + + + + + + + + + + + {user && ( + + {['월', '화', '수', '목', '금', '토', '일'].map((day, index) => { + const isActive = user.weeklyLog ? user.weeklyLog[index] : false; + return ( + + + {isActive && } - - - {summaryText} - - - - {item.servingDate} - - - - - ); - })} - - )} + {day} + + ); + })} + + )} + + + + + 오늘의 기사 + + {articles.length === 0 ? ( + + + 아직 도착한 기사가 없어요. + + ) : ( + + {articles.map((item, i) => { + const inputRange = [ + (i - 1) * SNAP_INTERVAL, + i * SNAP_INTERVAL, + (i + 1) * SNAP_INTERVAL, + ]; + const scale = scrollX.interpolate({ + inputRange, + outputRange: [0.9, 1, 0.9], + extrapolate: "clamp", + }); + const summaryText = item.summaryBullets && item.summaryBullets.length > 0 + ? item.summaryBullets.map(s => `• ${s}`).join("\n") + : "요약 내용이 없습니다."; + + return ( + + + + + {item.title} + + + + + + {summaryText} + + + {item.servingDate} + + + + + ); + })} + + )} + ); -} \ No newline at end of file +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: "#F8F9FA" }, + centerContainer: { flex: 1, justifyContent: "center", alignItems: "center" }, + dashboardWrapper: { paddingHorizontal: 24, marginTop: 20, marginBottom: 20 }, + dashboardCard: { + backgroundColor: "#FFF9E6", + borderRadius: 24, + padding: 24, + shadowColor: "#000", + shadowOpacity: 0.05, + shadowRadius: 10, + elevation: 2 + }, + profileHeader: { flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 10 }, + userName: { fontSize: 18, fontWeight: "700", color: "#333", marginBottom: 4 }, + dateText: { fontSize: 14, color: "#666" }, + streakWrapper: { flexDirection: "row", alignItems: "center", marginTop: 8 }, + streakEmoji: { fontSize: 16 }, + streakText: { fontSize: 15, fontWeight: "600", color: "#FF6B00", marginLeft: 4 }, + profileImageContainer: { + width: 50, height: 50, borderRadius: 25, backgroundColor: "#fff", + alignItems: "center", justifyContent: "center", overflow: "hidden", + borderWidth: 1, borderColor: "#eee" + }, + profileImage: { width: 40, height: 40, borderRadius: 20, resizeMode: "cover" }, + weeklyLogContainer: { flexDirection: "row", justifyContent: "space-between", marginTop: 10 }, + dayCircle: { + width: 32, height: 32, borderRadius: 16, + alignItems: "center", justifyContent: "center", marginBottom: 6 + }, + checkMark: { color: "#fff", fontWeight: "bold", fontSize: 12 }, + dayText: { fontSize: 12 }, + sectionTitle: { fontSize: 20, fontWeight: "700", marginLeft: 24, marginBottom: 16, color: "#191F28" }, + emptyContainer: { alignItems: "center", marginTop: 50, paddingHorizontal: 40 }, + emptyImage: { width: 80, height: 80, marginBottom: 10, opacity: 0.5 }, + emptyText: { color: "#999", fontSize: 14 }, + articleCard: { + backgroundColor: "#fff", + borderRadius: 20, + padding: 24, + marginRight: 10, + shadowColor: "#000", + shadowOpacity: 0.08, + shadowRadius: 12, + elevation: 6, + }, + articleTitle: { fontSize: 19, fontWeight: "700", marginBottom: 14, lineHeight: 26 }, + articleImageWrapper: { alignItems: "center", marginBottom: 20 }, + articleKnightImage: { height: 130, width: 130, resizeMode: "contain" }, + articleSummary: { fontSize: 14, lineHeight: 22, color: "#555", flex: 1 }, + articleDate: { fontSize: 12, color: "#999", textAlign: "right", marginTop: 14 }, +}); \ No newline at end of file diff --git a/app/(tabs)/my/index.tsx b/app/(tabs)/my/index.tsx index 03fa039..5afd2b9 100644 --- a/app/(tabs)/my/index.tsx +++ b/app/(tabs)/my/index.tsx @@ -1,170 +1,203 @@ // oba_fronted/app/(tabs)/my/index.tsx -// oba_fronted/app/(tabs)/my/index.tsx -import React, { useState, useEffect } from "react"; -import { - View, - Text, - Image, - TouchableOpacity, - StyleSheet, - Modal, - TextInput, - Alert, - ActivityIndicator, - Platform, -} from "react-native"; -import { useRouter } from "expo-router"; +import { View, Text, StyleSheet, Image, TouchableOpacity, Alert, ScrollView } from "react-native"; +import { useRouter, useFocusEffect } from "expo-router"; +import React, { useState, useCallback } from "react"; +import * as SecureStore from "expo-secure-store"; import { Ionicons } from "@expo/vector-icons"; -// import { apiClient } from "../../src/api/apiClient"; // 백엔드 API 완성 시 주석 해제 +import { useSafeAreaInsets } from "react-native-safe-area-context"; -type UserProfile = { - nickname: string; - email: string; - profileImage: any; -}; +// ✅ 경로 확인 (본인 프로젝트 구조에 맞게) +import { apiClient } from "../../../src/api/apiClient"; +import PizzaMenu from "../../components/PizzaMenu"; export default function MyPage() { const router = useRouter(); - const [userProfile, setUserProfile] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [modalVisible, setModalVisible] = useState(false); - const [inputText, setInputText] = useState(""); + const insets = useSafeAreaInsets(); + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); - const fetchAllData = async () => { - try { - console.log("[Client] 유저 정보를 요청합니다..."); - - // ✅ 실제 API 연동 시 아래 주석 해제 - // const res = await apiClient.get("/user/me"); - // setUserProfile(res.data); + // 화면이 포커스될 때마다 유저 정보 갱신 + useFocusEffect( + useCallback(() => { + fetchUser(); + }, []) + ); - // 현재는 더미 데이터 사용 - await new Promise((resolve) => setTimeout(resolve, 800)); - - const mockUser: UserProfile = { - nickname: "김제니", - email: "demo@oba.com", - profileImage: require("../../../assets/knight/basic_profile.png"), - }; - setUserProfile(mockUser); - - } catch (error) { - console.error("데이터 로딩 실패:", error); - Alert.alert("오류", "데이터를 불러오지 못했습니다."); + const fetchUser = async () => { + try { + const res = await apiClient.get("/api/users/me"); + // 유저 정보가 있고 이름이 유효한 경우만 세팅 + if (res.data && res.data.name) { + setUser(res.data); + } else { + setUser(null); + } + } catch (e) { + console.log("Guest 모드: 유저 정보를 불러올 수 없습니다."); + setUser(null); } finally { - setIsLoading(false); + setLoading(false); } }; - useEffect(() => { - fetchAllData(); - }, []); - - const openEditModal = () => { - if (!userProfile) return; - setInputText(userProfile.nickname); - setModalVisible(true); + const handleLogout = async () => { + Alert.alert("로그아웃", "정말 로그아웃 하시겠습니까?", [ + { text: "취소", style: "cancel" }, + { + text: "확인", + style: "destructive", + onPress: async () => { + await SecureStore.deleteItemAsync("accessToken"); + await SecureStore.deleteItemAsync("refreshToken"); + setUser(null); + router.replace("/(tabs)"); + }, + }, + ]); }; - const handleSaveNickname = async () => { - if (inputText.trim() === "") { - Alert.alert("알림", "닉네임을 입력해주세요."); - return; - } - try { - // await apiClient.post("/user/nickname", { nickname: inputText }); - setUserProfile((prev) => prev ? { ...prev, nickname: inputText } : null); - setModalVisible(false); - Alert.alert("성공", "닉네임이 수정되었습니다."); - } catch (error) { - Alert.alert("오류", "닉네임 수정 실패"); - } + const handleLoginNavigation = () => { + router.push("/(auth)/login"); }; - const renderHeader = () => { - if (!userProfile) return null; - return ( - - - - - - - - {userProfile.nickname} - - - {userProfile.email} + return ( + + + + {/* 헤더 섹션 */} + + {/* 뒤로가기 버튼 */} + + router.replace("/(tabs)")} style={styles.backButton}> + + - - - ); - }; - return ( - - {isLoading ? ( - - - 정보를 불러오는 중... - - ) : ( - - {renderHeader()} - - 내 정보 및 설정을 확인하세요. + {/* 프로필 이미지 */} + + + + {/* 이름 & 이메일 (로그인 여부에 따라 텍스트 변경) */} + + {user ? user.name : "로그인이 필요합니다"} + + {user ? ( + {user.email} + ) : ( + 회원가입하고 학습 기록을 남겨보세요! + )} - )} - - {/* 닉네임 수정 모달 */} - setModalVisible(false)}> - - - 닉네임 수정 - - - setModalVisible(false)}> - 취소 - - - 저장 - + + {/* 메뉴 리스트 */} + + router.push("/(tabs)/wrongArticles")}> + + + 오답 노트 - + + - + + {/* ✅ 핵심: 상태에 따라 버튼 분기 (로그인 vs 로그아웃) */} + + {user ? ( + + 로그아웃 + + ) : ( + + 로그인 하기 + + )} + + + + + {/* 피자 메뉴는 항상 표시 */} + ); } const styles = StyleSheet.create({ - container: { flex: 1, backgroundColor: "#F5FAFF" }, - headerSection: { paddingTop: 60, paddingHorizontal: 20, paddingBottom: 30, backgroundColor: "#F5FAFF" }, - trendyCard: { flexDirection: "row", alignItems: "center", backgroundColor: "#ffffff", padding: 24, borderRadius: 24, ...Platform.select({ ios: { shadowColor: "#000", shadowOffset: { width: 0, height: 8 }, shadowOpacity: 0.08, shadowRadius: 12 }, android: { elevation: 6 } }), borderWidth: 1, borderColor: "#F2F4F6" }, - profileLeft: { marginRight: 18 }, - trendyImage: { width: 72, height: 72, borderRadius: 36, backgroundColor: "#F2F4F6", borderWidth: 2, borderColor: "#fff" }, - profileRight: { flex: 1, justifyContent: "center" }, - nameRow: { flexDirection: "row", alignItems: "center", marginBottom: 6 }, - userName: { fontSize: 22, fontWeight: "800", color: "#1A1A1A", marginRight: 8 }, - userId: { fontSize: 14, color: "#8E8E93", fontWeight: "500" }, - loadingContainer: { flex: 1, justifyContent: "center", alignItems: "center" }, - loadingText: { marginTop: 12, color: "#8E8E93", fontSize: 15 }, - modalOverlay: { flex: 1, backgroundColor: "rgba(0,0,0,0.5)", justifyContent: "center", alignItems: "center" }, - modalContent: { width: "85%", backgroundColor: "white", borderRadius: 20, padding: 28, alignItems: "center", elevation: 5 }, - modalTitle: { fontSize: 20, fontWeight: "bold", marginBottom: 20, color: "#1A1A1A" }, - input: { width: "100%", height: 52, borderWidth: 1, borderColor: "#E5E5EA", borderRadius: 12, paddingHorizontal: 16, marginBottom: 24, fontSize: 16, backgroundColor: "#F2F4F6" }, - modalButtons: { flexDirection: "row", width: "100%", gap: 12 }, - modalBtn: { flex: 1, paddingVertical: 14, borderRadius: 12, alignItems: "center", justifyContent: "center" }, - cancelBtn: { backgroundColor: "#F2F4F6" }, - saveBtn: { backgroundColor: "#007AFF" }, - cancelText: { fontSize: 16, color: "#8E8E93", fontWeight: "600" }, - saveText: { fontSize: 16, color: "white", fontWeight: "600" }, + header: { + alignItems: "center", + paddingBottom: 30, + backgroundColor: "#fff", + marginBottom: 20, + borderBottomLeftRadius: 24, + borderBottomRightRadius: 24, + shadowColor: "#000", + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.05, + shadowRadius: 10, + elevation: 3, + }, + navBarAbsolute: { position: "absolute", top: 50, left: 20, zIndex: 10 }, + backButton: { padding: 5 }, + profileImageWrap: { + width: 100, + height: 100, + borderRadius: 50, + overflow: "hidden", + marginBottom: 15, + backgroundColor: "#eee", + borderWidth: 1, + borderColor: "#f0f0f0" + }, + profileImage: { width: "100%", height: "100%" }, + name: { fontSize: 22, fontWeight: "bold", color: "#333", marginBottom: 4 }, + email: { fontSize: 14, color: "#888" }, + guestText: { fontSize: 14, color: "#aaa", marginTop: 4 }, + + menuContainer: { + backgroundColor: "#fff", + paddingHorizontal: 20, + marginHorizontal: 20, + borderRadius: 16, + marginBottom: 30, + }, + menuItem: { + paddingVertical: 18, + borderBottomWidth: 1, + borderBottomColor: "#f0f0f0", + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + }, + menuText: { fontSize: 16, color: "#333", fontWeight: "500" }, + + actionButtonContainer: { + alignItems: "center", + marginTop: 10, + }, + logoutButton: { + paddingVertical: 12, + paddingHorizontal: 30, + }, + logoutText: { color: "#FF3B30", fontSize: 16, fontWeight: "600" }, + + loginButton: { + backgroundColor: "#FF6B00", + paddingVertical: 14, + paddingHorizontal: 40, + borderRadius: 30, + shadowColor: "#FF6B00", + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.2, + shadowRadius: 8, + elevation: 4, + }, + loginText: { color: "#fff", fontSize: 16, fontWeight: "bold" }, }); \ No newline at end of file diff --git a/app/(tabs)/report/index.tsx b/app/(tabs)/report/index.tsx index cfc1683..7fa0d47 100644 --- a/app/(tabs)/report/index.tsx +++ b/app/(tabs)/report/index.tsx @@ -1,6 +1,5 @@ // oba_fronted/app/(tabs)/report/index.tsx -// app/(tabs)/report.tsx import { View, Text, StyleSheet } from "react-native"; export default function ReportPage() { diff --git a/app/(tabs)/wrongArticles/index.tsx b/app/(tabs)/wrongArticles/index.tsx index cb14c63..06122bf 100644 --- a/app/(tabs)/wrongArticles/index.tsx +++ b/app/(tabs)/wrongArticles/index.tsx @@ -1,692 +1,231 @@ -// oba_fronted/app/(tabs)/wrongArticles/index.tsx - -import React, { useState, useEffect } from "react"; -import { - View, - Text, - TouchableOpacity, - StyleSheet, - Alert, - FlatList, - ActivityIndicator, - RefreshControl, - Platform, +// oba_frontend//app/(tabs)/wrongArticles/index.tsx +import React, { useState, useCallback } from "react"; +import { + View, + Text, + TouchableOpacity, + StyleSheet, + FlatList, + ActivityIndicator, + RefreshControl, + Platform } from "react-native"; -import { useRouter } from "expo-router"; +import { useRouter, useFocusEffect } from "expo-router"; // useNavigation 삭제 import { Ionicons } from "@expo/vector-icons"; -import { apiClient } from "../../../src/api/apiClient"; +import * as SecureStore from "expo-secure-store"; +import { apiClient } from "../../../src/api/apiClient"; -// --------------------------------------------------------- -// 1. 데이터 타입 정의 (백엔드와 약속한 데이터 모양) -// --------------------------------------------------------- -type HistoryItem = { - article_id: number; - serving_date: string; +interface WrongArticle { + articleId: string; title: string; - category_name: string; - isWrong: boolean; -}; + summary: string; + solvedAt: string; + category?: string; +} export default function WrongArticlesPage() { const router = useRouter(); - - // --------------------------------------------------------- - // 상태 관리 (State) - // --------------------------------------------------------- - const [historyData, setHistoryData] = useState([]); + + const [articles, setArticles] = useState([]); const [isLoading, setIsLoading] = useState(true); + const [isLoggedIn, setIsLoggedIn] = useState(false); const [refreshing, setRefreshing] = useState(false); - - const [selectedCategory, setSelectedCategory] = useState("All"); - const [categories, setCategories] = useState([]); const [sortOrder, setSortOrder] = useState<"newest" | "oldest">("newest"); + const [selectedCategory, setSelectedCategory] = useState("All"); - // --------------------------------------------------------- - // 데이터 요청 - // --------------------------------------------------------- - const fetchHistory = async () => { + const categories = ["All", "Tech News", "Health Daily"]; + + useFocusEffect( + useCallback(() => { + // 🚨 [삭제됨] navigation.setOptions 코드 삭제. _layout.tsx가 숨김 처리함. + checkLoginAndFetch(); + }, []) + ); + + const checkLoginAndFetch = async () => { try { - const res = await apiClient.get("/my/wrong-answers"); - setHistoryData(res.data); + const token = await SecureStore.getItemAsync("accessToken"); + + if (!token) { + setIsLoggedIn(false); + setIsLoading(false); + setArticles([]); + return; + } + + setIsLoggedIn(true); + await fetchHistory(); + } catch (error) { + console.error("초기화 에러:", error); + } + }; - const cats = Array.from(new Set(res.data.map((h: HistoryItem) => h.category_name))); - setCategories(["All", ...cats]); + const fetchHistory = async () => { + try { + const res = await apiClient.get("/api/quiz/wrong"); + setArticles(res.data || []); } catch (error) { - console.error("데이터 로딩 실패:", error); - Alert.alert("오류", "데이터를 불러오지 못했습니다."); + console.log("오답노트 로딩 실패"); + setArticles([]); } finally { setIsLoading(false); setRefreshing(false); } }; - useEffect(() => { - fetchHistory(); - }, []); - - const onRefresh = () => { - setRefreshing(true); - fetchHistory(); + const getFilteredData = () => { + const currentArticles = articles || []; + let filtered = selectedCategory === "All" + ? currentArticles + : currentArticles.filter(a => a.category === selectedCategory); + + return [...filtered].sort((a, b) => { + const dateA = new Date(a.solvedAt).getTime(); + const dateB = new Date(b.solvedAt).getTime(); + return sortOrder === "newest" ? dateB - dateA : dateA - dateB; + }); }; - // --------------------------------------------------------- - // 리스트 헤더 (필터 + 정렬 포함) - // --------------------------------------------------------- - const renderHeader = () => ( - - - 틀린 기사 다시보기 - 최근 1년의 기사를 확인하세요 - - - - c} - showsHorizontalScrollIndicator={false} - renderItem={({ item }) => ( - setSelectedCategory(item)} - > - - {item} - - - )} - /> + const renderItem = ({ item, index }: { item: WrongArticle; index: number }) => { + const sortedData = getFilteredData(); + const isLast = index === sortedData.length - 1; + + return ( + + + + + {!isLast && } + + - setSortOrder((s) => (s === "newest" ? "oldest" : "newest"))} - > - - {sortOrder === "newest" ? "최신순" : "오래된 순"} - + + {item.solvedAt} + router.push(`/article/${item.articleId}`)} + > + {item.title} + {item.category && {item.category}} + + 다시 풀기 {'>'} + + + + ); + }; + + if (isLoading) return ( + + ); - // --------------------------------------------------------- - // 리스트 아이템 - // --------------------------------------------------------- - const renderItem = ({ item }: { item: HistoryItem }) => ( - - - - + // 비로그인 화면 + if (!isLoggedIn) { + return ( + + + router.back()} style={styles.backBtn}> + + + 오답 노트 + + + + + 로그인이 필요한 기능입니다. + router.push("/(auth)/login")} + > + 로그인 하러 가기 + + + ); + } - - {item.serving_date} - router.push(`/article/${item.article_id}`)} - > - {item.title} - {item.category_name} + return ( + + + router.back()} style={styles.backBtn}> + + + 틀린 기사 다시보기 + 최근 1년의 기사를 확인하세요 + - - ); - // --------------------------------------------------------- - // 메인 렌더링 - // --------------------------------------------------------- - return ( - - {isLoading ? ( - - - 정보를 불러오는 중... + + + {categories.map(cat => ( + setSelectedCategory(cat)} + > + {cat} + + ))} - ) : ( - selectedCategory === "All" ? true : h.category_name === selectedCategory) - .sort((a, b) => { - const norm = (s: string) => s.replace(/\./g, ""); - const ad = parseInt(norm(a.serving_date)); - const bd = parseInt(norm(b.serving_date)); - return sortOrder === "newest" ? bd - ad : ad - bd; - })} - renderItem={renderItem} - keyExtractor={(item) => item.article_id.toString()} - ListHeaderComponent={renderHeader} - contentContainerStyle={styles.listContentContainer} - showsVerticalScrollIndicator={false} - refreshControl={} - ListEmptyComponent={ - - 틀린 문제가 없습니다 🎉 - - } - /> - )} + setSortOrder(s => s === "newest" ? "oldest" : "newest")} + > + + {sortOrder === "newest" ? "최신순" : "오래된 순"} + + + + item.articleId ? item.articleId.toString() : `item-${index}`} + contentContainerStyle={styles.listContainer} + refreshControl={ { setRefreshing(true); fetchHistory(); }} />} + ListEmptyComponent={틀린 문제가 없습니다 🎉} + /> ); } - -// ----------------------------------------------------- -// 스타일 정의 -// ----------------------------------------------------- const styles = StyleSheet.create({ - container: { flex: 1, backgroundColor: "#F5FAFF" }, // 전체 배경 기본 backgroundcolor와 동일하게 - - // FlatList 내부 여백 (헤더 포함 전체 리스트) - listContentContainer: { - paddingBottom: 40, - }, - - // --- 1. 프로필 카드 스타일 --- - headerSection: { - paddingTop: 60, // 상태바 여백 - paddingHorizontal: 20, - paddingBottom: 30, - backgroundColor: "#F5FAFF", // 스크롤 시 위쪽 배경 흰색 유지 - }, - trendyCard: { - flexDirection: "row", - alignItems: "center", - backgroundColor: "#ffffff", - padding: 24, - borderRadius: 24, - // 그림자 강화 - ...Platform.select({ - ios: { - shadowColor: "#000", - shadowOffset: { width: 0, height: 8 }, - shadowOpacity: 0.08, - shadowRadius: 12, - }, - android: { elevation: 6 }, - }), - borderWidth: 1, - borderColor: "#F2F4F6", - }, - profileLeft: { marginRight: 18 }, - trendyImage: { - width: 72, - height: 72, - borderRadius: 36, - backgroundColor: "#F2F4F6", - borderWidth: 2, - borderColor: "#fff", - }, - profileRight: { flex: 1, justifyContent: "center" }, - nameRow: { flexDirection: "row", alignItems: "center", marginBottom: 6 }, - userName: { fontSize: 22, fontWeight: "800", color: "#1A1A1A", marginRight: 8 }, - userId: { fontSize: 14, color: "#8E8E93", fontWeight: "500" }, - - // --- 2. 타임라인 헤더 (제목) --- - timelineHeader: { - paddingHorizontal: 24, - paddingBottom: 10, - backgroundColor: "#F5FAFF", // 리스트와 자연스럽게 연결 - }, - sectionTitle: { fontSize: 18, fontWeight: "700", color: "#1A1A1A", marginBottom: 6 }, - sectionSubtitle: { fontSize: 14, color: "#8E8E93", marginBottom: 20 }, - - // 필터/정렬 바 - filterBar: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", marginTop: 8 }, - categoryBtn: { paddingVertical: 6, paddingHorizontal: 12, backgroundColor: "#fff", borderRadius: 20, borderWidth: 1, borderColor: "#ECEFF5", marginRight: 8 }, - categoryBtnActive: { backgroundColor: "#007AFF", borderColor: "#007AFF" }, - categoryText: { fontSize: 13, color: "#333" }, - categoryTextActive: { color: "#fff", fontWeight: "700" }, - sortBtn: { flexDirection: "row", alignItems: "center", padding: 8, marginLeft: 8 }, - sortText: { marginLeft: 6, color: "#007AFF", fontWeight: "600" }, - - // --- 3. 타임라인 아이템 (리스트 내부) --- - timelineItem: { - flexDirection: "row", - paddingHorizontal: 24, // 리스트 좌우 여백 - marginBottom: 0, - }, - timelineLeft: { width: 32, alignItems: "center", marginRight: 12 }, - line: { position: "absolute", top: 0, bottom: 0, width: 2, backgroundColor: "#E5E5EA" }, // 밝은 회색 줄 - dot: { - marginTop: 24, - width: 14, height: 14, borderRadius: 7, - backgroundColor: "#007AFF", - borderWidth: 3, borderColor: "#fff", - zIndex: 1 - }, - timelineRight: { flex: 1, paddingBottom: 28 }, - dateText: { fontSize: 14, fontWeight: "600", color: "#8E8E93", marginBottom: 10 }, + container: { flex: 1, backgroundColor: "#F8FAFF" }, + loading: { flex: 1, justifyContent: "center", alignItems: "center" }, + header: { paddingHorizontal: 20, paddingTop: 60, marginBottom: 20, flexDirection: 'row', alignItems: 'center' }, + headerRow: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 20, paddingTop: 60, marginBottom: 20 }, + backBtn: { marginRight: 15 }, + headerTitle: { fontSize: 22, fontWeight: "bold", color: "#1A1A1A" }, + headerSub: { fontSize: 14, color: "#8E8E93", marginTop: 5 }, + filterBar: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", paddingHorizontal: 20, marginBottom: 20 }, + categoryScroll: { flexDirection: "row", gap: 8 }, + catBtn: { paddingHorizontal: 12, paddingVertical: 6, borderRadius: 20, backgroundColor: "#fff", borderWidth: 1, borderColor: "#E5E5EA" }, + catBtnActive: { backgroundColor: "#4A8CFF", borderColor: "#4A8CFF" }, + catText: { fontSize: 13, color: "#48484A" }, + catTextActive: { color: "#fff", fontWeight: "bold" }, + sortBtn: { flexDirection: "row", alignItems: "center", padding: 5 }, + sortText: { fontSize: 13, color: "#4A8CFF", marginLeft: 4, fontWeight: "600" }, + listContainer: { paddingHorizontal: 20, paddingBottom: 40 }, + timelineItem: { flexDirection: "row" }, + timelineLeft: { width: 30, alignItems: "center" }, + dotContainer: { alignItems: "center", flex: 1 }, + dot: { width: 10, height: 10, borderRadius: 5, backgroundColor: "#4A8CFF", zIndex: 1, marginTop: 8 }, + line: { width: 2, backgroundColor: "#E5E5EA", flex: 1, marginTop: -2 }, + timelineRight: { flex: 1, paddingBottom: 30, marginLeft: 10 }, + dateText: { fontSize: 13, color: "#8E8E93", marginBottom: 8, fontWeight: "500" }, articleCard: { - backgroundColor: "#fff", - borderRadius: 16, - padding: 20, - // 카드 그림자 - ...Platform.select({ - ios: { - shadowColor: "#000", - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.05, - shadowRadius: 8, - }, - android: { elevation: 2 }, - }), - borderWidth: 1, - borderColor: "#F2F4F6", + backgroundColor: "#fff", borderRadius: 16, padding: 18, borderWidth: 1, borderColor: "#F2F4F6", + ...Platform.select({ ios: { shadowColor: "#000", shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.05, shadowRadius: 8 }, android: { elevation: 2 } }) }, - articleTitle: { fontSize: 15, fontWeight: "600", color: "#1A1A1A", marginBottom: 8, lineHeight: 24 }, - articleCategory: { fontSize: 13, fontWeight: "500", color: "#007AFF" }, - - // 로딩 및 기타 스타일 - loadingContainer: { flex: 1, justifyContent: "center", alignItems: "center" }, - loadingText: { marginTop: 12, color: "#8E8E93", fontSize: 15 }, - emptyContainer: { alignItems: "center", marginTop: 60 }, + articleTitle: { fontSize: 16, fontWeight: "bold", color: "#1A1A1A", lineHeight: 22 }, + cardCategory: { fontSize: 12, color: "#4A8CFF", marginTop: 8, fontWeight: "600" }, + cardFooter: { marginTop: 12, paddingTop: 10, borderTopWidth: 1, borderTopColor: "#F2F4F6", alignItems: "flex-end" }, + retryText: { fontSize: 12, color: "#4A8CFF", fontWeight: "600" }, + empty: { flex: 1, justifyContent: "center", alignItems: "center", marginTop: 50 }, emptyText: { color: "#999", fontSize: 16 }, - - // 모달 스타일 - modalOverlay: { flex: 1, backgroundColor: "rgba(0,0,0,0.5)", justifyContent: "center", alignItems: "center" }, - modalContent: { width: "85%", backgroundColor: "white", borderRadius: 20, padding: 28, alignItems: "center", elevation: 5 }, - modalTitle: { fontSize: 20, fontWeight: "bold", marginBottom: 20, color: "#1A1A1A" }, - input: { width: "100%", height: 52, borderWidth: 1, borderColor: "#E5E5EA", borderRadius: 12, paddingHorizontal: 16, marginBottom: 24, fontSize: 16, backgroundColor: "#F2F4F6" }, - modalButtons: { flexDirection: "row", width: "100%", gap: 12 }, - modalBtn: { flex: 1, paddingVertical: 14, borderRadius: 12, alignItems: "center", justifyContent: "center" }, - cancelBtn: { backgroundColor: "#F2F4F6" }, - saveBtn: { backgroundColor: "#007AFF" }, - cancelText: { fontSize: 16, color: "#8E8E93", fontWeight: "600" }, - saveText: { fontSize: 16, color: "white", fontWeight: "600" }, -}); - -// import React, { useState, useEffect } from "react"; -// import { -// View, -// Text, -// Image, -// TouchableOpacity, -// StyleSheet, -// Modal, -// TextInput, -// Alert, -// FlatList, -// ActivityIndicator, -// RefreshControl, -// Platform, -// } from "react-native"; -// import { useRouter } from "expo-router"; -// import { Ionicons } from "@expo/vector-icons"; -// // import { apiClient } from "@/src/api/apiClient"; // (나중에 실제 연동 시 주석 해제) - -// // --------------------------------------------------------- -// // 1. 데이터 타입 정의 -// // --------------------------------------------------------- -// type UserProfile = { -// nickname: string; -// email: string; -// profileImage: any; -// }; - -// type HistoryItem = { -// article_id: number; // bigint는 JS에서 number나 string으로 처리됨 -// serving_date: string; -// title: string; -// category_name: string; -// isWrong: boolean; -// }; - -// export default function MyPage() { -// const router = useRouter(); - -// // --------------------------------------------------------- -// // 상태 관리 (State) -// // --------------------------------------------------------- -// const [userProfile, setUserProfile] = useState(null); -// const [historyData, setHistoryData] = useState([]); -// const [isLoading, setIsLoading] = useState(true); -// const [refreshing, setRefreshing] = useState(false); - -// const [modalVisible, setModalVisible] = useState(false); -// const [inputText, setInputText] = useState(""); - -// // --------------------------------------------------------- -// // 데이터 통합 요청 -// // --------------------------------------------------------- -// const fetchAllData = async () => { -// try { -// console.log("[Client] 유저 정보와 타임라인 데이터를 동시에 요청합니다..."); - -// // 백엔드 응답 시간 시뮬레이션 -// await new Promise((resolve) => setTimeout(resolve, 1000)); - -// const mockUser: UserProfile = { -// nickname: "김제니", -// email: "hwimin@kakao.com", -// profileImage: require("../../../assets/knight/basic_profile.png"), -// }; - -// const mockHistory: HistoryItem[] = [ -// { article_id: 1, serving_date: "2025.03.10", title: "전기차 배터리 기술의 새로운 돌파구, 충전 시간 10분으로 단축", category_name: "Tech News", isWrong: true }, -// { article_id: 2, serving_date: "2025.03.08", title: "AI가 의료 진단 정확도 95%까지 향상시켰다", category_name: "Health Daily", isWrong: true }, -// { article_id: 3, serving_date: "2025.03.05", title: "“AI 에이전트는 아직 ‘말 없는 마차’ 수준…완전한 자율화는 먼 미래”", category_name: "Environment Weekly", isWrong: true }, -// { article_id: 4, serving_date: "2025.03.05", title: "AI 시대에도 ‘개방성’이 힘을 가질까?", category_name: "Environment Weekly", isWrong: true }, -// { article_id: 5, serving_date: "2025.03.03", title: "보안 행동과 인식 수준을 높이는 핵심 전략 ‘공감 기반 정책 엔지니어링’", category_name: "Environment Weekly", isWrong: true }, -// ]; - -// setUserProfile(mockUser); -// setHistoryData(mockHistory); - -// } catch (error) { -// console.error("데이터 로딩 실패:", error); -// Alert.alert("오류", "데이터를 불러오지 못했습니다."); -// } finally { -// setIsLoading(false); -// setRefreshing(false); -// } -// }; - -// useEffect(() => { -// fetchAllData(); -// }, []); - -// const onRefresh = () => { -// setRefreshing(true); -// fetchAllData(); -// }; - -// // --------------------------------------------------------- -// // 닉네임 수정 로직 -// // --------------------------------------------------------- -// const openEditModal = () => { -// if (!userProfile) return; -// setInputText(userProfile.nickname); -// setModalVisible(true); -// }; - -// const handleSaveNickname = async () => { -// if (inputText.trim() === "") { -// Alert.alert("알림", "닉네임을 입력해주세요."); -// return; -// } -// try { -// console.log(`[서버 전송] 닉네임 변경 요청: ${inputText}`); -// setUserProfile((prev) => prev ? { ...prev, nickname: inputText } : null); -// setModalVisible(false); -// Alert.alert("성공", "닉네임이 수정되었습니다."); -// } catch (error) { -// Alert.alert("오류", "닉네임 수정 실패"); -// } -// }; - -// // --------------------------------------------------------- -// // 리스트 헤더 (프로필 + 제목) -// // --------------------------------------------------------- -// const renderHeader = () => { -// if (!userProfile) return null; - -// return ( -// -// {/* 1. 프로필 카드 영역 */} -// -// -// -// - -// {/* 🍕 [추가] 프로필 이미지 위에 작은 피자 뱃지 포인트 */} -// -// -// -// - -// -// -// {userProfile.nickname} -// {/* 🍕 [변경] 펜 아이콘을 피자색 포인트로 변경 */} -// -// -// {userProfile.email} -// -// -// - -// {/* 2. 타임라인 제목 영역 */} -// -// -// 틀린 기사 다시보기 -// -// 최근 1년의 기사를 최신순으로 확인하세요! -// -// -// ); -// }; - -// // --------------------------------------------------------- -// // 리스트 아이템 (타임라인) -// // --------------------------------------------------------- -// const renderItem = ({ item }: { item: HistoryItem }) => ( -// -// {/* 🍕 [변경] 왼쪽 라인 & 피자 아이콘 점 */} -// -// - -// {/* 기존의 단순 dot 대신 피자 아이콘 사용 */} -// -// -// -// - -// {/* 오른쪽 카드 내용 */} -// -// {item.serving_date} -// router.push(`/article/${item.article_id}`)} -// > -// {item.title} -// -// {item.category_name} -// -// -// -// -// ); - -// // --------------------------------------------------------- -// // 메인 렌더링 -// // --------------------------------------------------------- -// return ( -// -// {isLoading ? ( -// -// {/* 🍕 [변경] 로딩 스피너 색상을 피자 치즈/소스 색상으로 */} -// -// 토핑 올리는 중... -// -// ) : ( -// item.article_id.toString()} - -// ListHeaderComponent={renderHeader} - -// contentContainerStyle={styles.listContentContainer} -// showsVerticalScrollIndicator={false} -// refreshControl={ -// // 🍕 [변경] 당겨서 새로고침 색상도 피자 컬러로 -// -// } -// ListEmptyComponent={ -// -// {/* 🍕 [추가] 데이터 없을 때 대왕 피자 아이콘 */} -// -// 아직 틀린 문제가 없어요! -// 맛있는 피자를 굽는 중... 🍕 -// -// } -// /> -// )} - -// {/* 닉네임 수정 모달 */} -// setModalVisible(false)}> -// -// -// {/* 🍕 [추가] 모달 제목 옆 아이콘 */} -// -// 닉네임 수정 -// -// - -// -// -// setModalVisible(false)}> -// 취소 -// -// -// 저장 -// -// -// -// -// -// -// ); -// } - -// // ----------------------------------------------------- -// // 스타일 정의 (기존 스타일 유지 + Pizza 포인트 추가) -// // ----------------------------------------------------- -// const styles = StyleSheet.create({ -// container: { flex: 1, backgroundColor: "#F5FAFF" }, - -// listContentContainer: { -// paddingBottom: 40, -// }, - -// // --- 1. 프로필 카드 --- -// headerSection: { -// paddingTop: 60, -// paddingHorizontal: 20, -// paddingBottom: 30, -// backgroundColor: "#F5FAFF", -// }, -// trendyCard: { -// flexDirection: "row", -// alignItems: "center", -// backgroundColor: "#ffffff", -// padding: 24, -// borderRadius: 24, -// ...Platform.select({ -// ios: { -// shadowColor: "#000", -// shadowOffset: { width: 0, height: 8 }, -// shadowOpacity: 0.08, -// shadowRadius: 12, -// }, -// android: { elevation: 6 }, -// }), -// borderWidth: 1, -// borderColor: "#F2F4F6", -// }, -// profileLeft: { -// marginRight: 18, -// position: 'relative', // 뱃지 위치 잡기 위해 -// }, -// trendyImage: { -// width: 72, -// height: 72, -// borderRadius: 36, -// backgroundColor: "#F2F4F6", -// borderWidth: 2, -// borderColor: "#fff", -// }, -// // 🍕 피자 뱃지 스타일 -// pizzaBadge: { -// position: 'absolute', -// bottom: 0, -// right: 0, -// backgroundColor: '#007AFF', -// width: 24, -// height: 24, -// borderRadius: 12, -// alignItems: 'center', -// justifyContent: 'center', -// borderWidth: 2, -// borderColor: '#fff', -// }, -// profileRight: { flex: 1, justifyContent: "center" }, -// nameRow: { flexDirection: "row", alignItems: "center", marginBottom: 6 }, -// userName: { fontSize: 22, fontWeight: "800", color: "#1A1A1A", marginRight: 8 }, -// userId: { fontSize: 14, color: "#8E8E93", fontWeight: "500" }, - -// // --- 2. 타임라인 헤더 --- -// timelineHeader: { -// paddingHorizontal: 24, -// paddingBottom: 10, -// backgroundColor: "#F5FAFF", -// }, -// sectionTitle: { fontSize: 18, fontWeight: "700", color: "#1A1A1A" }, // margin removed for row layout -// sectionSubtitle: { fontSize: 14, color: "#8E8E93", marginBottom: 20 }, - -// // --- 3. 타임라인 아이템 --- -// timelineItem: { -// flexDirection: "row", -// paddingHorizontal: 24, -// marginBottom: 0, -// }, -// timelineLeft: { width: 32, alignItems: "center", marginRight: 12 }, -// line: { position: "absolute", top: 0, bottom: 0, width: 2, backgroundColor: "#E5E5EA" }, - -// // 🍕 기존 dot 대신 아이콘 컨테이너 -// pizzaDotContainer: { -// marginTop: 24, -// width: 24, -// height: 24, -// alignItems: 'center', -// justifyContent: 'center', -// backgroundColor: '#F5FAFF', // 라인을 가리기 위한 배경색 -// zIndex: 1, -// }, - -// timelineRight: { flex: 1, paddingBottom: 28 }, -// dateText: { fontSize: 14, fontWeight: "600", color: "#8E8E93", marginBottom: 10 }, -// articleCard: { -// backgroundColor: "#fff", -// borderRadius: 16, -// padding: 20, -// ...Platform.select({ -// ios: { -// shadowColor: "#000", -// shadowOffset: { width: 0, height: 4 }, -// shadowOpacity: 0.05, -// shadowRadius: 8, -// }, -// android: { elevation: 2 }, -// }), -// borderWidth: 1, -// borderColor: "#F2F4F6", -// }, -// articleTitle: { fontSize: 15, fontWeight: "600", color: "#1A1A1A", marginBottom: 8, lineHeight: 24 }, -// categoryRow: { flexDirection: 'row', alignItems: 'center' }, -// articleCategory: { fontSize: 13, fontWeight: "500", color: "#007AFF" }, - -// // 로딩 및 빈 상태 -// loadingContainer: { flex: 1, justifyContent: "center", alignItems: "center" }, -// loadingText: { marginTop: 12, color: "#FF6347", fontSize: 15, fontWeight: "600" }, -// emptyContainer: { alignItems: "center", marginTop: 60 }, -// emptyText: { color: "#999", fontSize: 16, fontWeight: "bold" }, -// emptySubText: { color: "#bbb", fontSize: 14, marginTop: 4 }, - -// // 모달 스타일 -// modalOverlay: { flex: 1, backgroundColor: "rgba(0,0,0,0.5)", justifyContent: "center", alignItems: "center" }, -// modalContent: { width: "85%", backgroundColor: "white", borderRadius: 20, padding: 28, alignItems: "center", elevation: 5 }, -// modalTitle: { fontSize: 20, fontWeight: "bold", color: "#1A1A1A" }, // marginBottom removed for row layout -// input: { width: "100%", height: 52, borderWidth: 1, borderColor: "#E5E5EA", borderRadius: 12, paddingHorizontal: 16, marginBottom: 24, fontSize: 16, backgroundColor: "#F2F4F6" }, -// modalButtons: { flexDirection: "row", width: "100%", gap: 12 }, -// modalBtn: { flex: 1, paddingVertical: 14, borderRadius: 12, alignItems: "center", justifyContent: "center" }, -// cancelBtn: { backgroundColor: "#F2F4F6" }, -// saveBtn: { backgroundColor: "#FF6347" }, // 🍕 저장 버튼도 피자색 -// cancelText: { fontSize: 16, color: "#8E8E93", fontWeight: "600" }, -// saveText: { fontSize: 16, color: "white", fontWeight: "600" }, -// }); \ No newline at end of file + loginLinkBtn: { marginTop: 20, padding: 10 }, + loginLinkText: { color: "#4A8CFF", fontWeight: "bold", fontSize: 16 }, +}); \ No newline at end of file diff --git a/app/_layout.tsx b/app/_layout.tsx index 801383b..733496d 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,44 +1,21 @@ -import { DarkTheme, DefaultTheme, ThemeProvider } from "@react-navigation/native"; -import { Stack } from "expo-router"; -import { StatusBar } from "expo-status-bar"; +// oba_frontend/app/_layout.tsx +import { Slot } from "expo-router"; +import { SafeAreaProvider } from "react-native-safe-area-context"; import { View } from "react-native"; -import "react-native-reanimated"; -import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context"; -import AppBackground from "./components/AppBackground"; -// 🔥 수정된 테마 설정 (오타 제거됨) -const MyTheme = { - ...DefaultTheme, - colors: { - ...DefaultTheme.colors, - background: "transparent", - }, -}; +// 만약 에러가 난다면 "../src/components/AppBackground" 로 경로를 수정해보세요. +import AppBackground from "./components/AppBackground"; export default function RootLayout() { return ( - - - {/* 전체 배경 적용 */} - - - - {/* 네비게이션 스택 */} - - - {/* 탭 화면 */} - - {/* 인증(로그인) 화면 */} - - {/* 기사 상세 화면 (동적 라우팅) */} - - - - - - - + + {/* 전체 배경 적용 */} + + + {/* 화면이 표시되는 영역 */} + + ); } \ No newline at end of file diff --git a/app/article/[id].tsx b/app/article/[id].tsx index 2602fd4..5610f93 100644 --- a/app/article/[id].tsx +++ b/app/article/[id].tsx @@ -1,10 +1,11 @@ // oba_fronted/app/article/[id].tsx import { useState, useEffect } from "react"; -import { View, Text, ActivityIndicator, Alert } from "react-native"; +import { View, ActivityIndicator, Alert, StyleSheet } from "react-native"; import { useRouter, useLocalSearchParams } from "expo-router"; +import * as SecureStore from "expo-secure-store"; + import TabBar from "./components/TabBar"; import ArticleTab from "./components/ArticleTab"; -import SummaryTab from "./components/SummaryTab"; import KeywordTab from "./components/KeywordTab"; import QuizTab from "./components/QuizTab"; import { apiClient } from "../../src/api/apiClient"; @@ -12,30 +13,55 @@ import { apiClient } from "../../src/api/apiClient"; export default function ArticleDetail() { const router = useRouter(); const { id } = useLocalSearchParams(); + const [activeTab, setActiveTab] = useState("기사"); - const [article, setArticle] = useState(null); + const [article, setArticle] = useState(null); const [loading, setLoading] = useState(true); - + // 퀴즈 상태 - const [selected, setSelected] = useState({}); - const [isGraded, setIsGraded] = useState([]); - const [isOpen, setIsOpen] = useState({}); + const [selected, setSelected] = useState({}); + const [isGraded, setIsGraded] = useState([]); + const [isOpen, setIsOpen] = useState({}); + const [myQuizResults, setMyQuizResults] = useState([]); useEffect(() => { if (!id) return; - const fetchArticle = async () => { try { - const res = await apiClient.get(`/articles/${id}`); - setArticle(res.data); - // 퀴즈가 있다면 초기화 - if (res.data.quizzes) { - setIsGraded(new Array(res.data.quizzes.length).fill(false)); + const res = await apiClient.get(`/api/articles/${id}`); + const data = res.data; + + // 데이터 파싱 방어 로직 + if (data.gpt_result && typeof data.gpt_result === 'string') { + try { data.gpt_result = JSON.parse(data.gpt_result); } catch(e) {} + } + if (data.gptResult && typeof data.gptResult === 'string') { + try { data.gptResult = JSON.parse(data.gptResult); } catch(e) {} } + + setArticle(data); + + // 퀴즈 데이터 초기화 + const gptData = data.gpt_result || data.gptResult || data.gptResults || data.GPTResult || {}; + const quizzes = data.quizzes || gptData.quizzes || []; + + if (quizzes.length > 0) { + // 기존에 푼 기록이 있으면 불러오기, 없으면 false로 초기화 + if (data.myQuizResults && data.myQuizResults.length > 0) { + setMyQuizResults(data.myQuizResults); + // 이미 푼 문제는 채점 된 상태로 표시 + const gradedState = new Array(quizzes.length).fill(true); + setIsGraded(gradedState); + } else { + setIsGraded(new Array(quizzes.length).fill(false)); + } + } + } catch (err) { - console.error("기사 상세 로딩 실패:", err); + console.error("Fetch Error:", err); Alert.alert("오류", "기사를 불러올 수 없습니다."); - router.back(); + if (router.canGoBack()) router.back(); + else router.replace("/"); } finally { setLoading(false); } @@ -43,54 +69,121 @@ export default function ArticleDetail() { fetchArticle(); }, [id]); - if (loading) { - return ( - - - - ); - } + const handleMoveToQuiz = async () => { + const token = await SecureStore.getItemAsync("accessToken"); + if (!token) { + Alert.alert("로그인 필요", "퀴즈는 로그인 후 이용할 수 있습니다.", [ + { text: "취소", style: "cancel" }, + { text: "로그인", onPress: () => router.push("/(auth)/login") } + ]); + return; + } + setActiveTab("퀴즈"); + }; - if (!article) return null; + // ✅ 정답 인덱스 추출 헬퍼 함수 (QuizTab과 동일한 로직) + const getCorrectIndex = (quiz: any) => { + return quiz.answerIndex !== undefined + ? Number(quiz.answerIndex) + : (parseInt(quiz.answer?.replace(/[^0-9]/g, "") || "0") - 1); + }; + + // ✅ 퀴즈 결과 서버 전송 함수 + const submitQuizResult = async (currentResults: boolean[]) => { + try { + const articleIdStr = Array.isArray(id) ? id[0] : id; + console.log("📤 퀴즈 결과 전송:", currentResults); + await apiClient.post("/api/quiz/result", { + articleId: articleIdStr, + results: currentResults + }); + } catch (error) { + console.error("퀴즈 저장 실패:", error); + } + }; + + // ✅ [핵심 수정] 채점 버튼 클릭 시 서버로 데이터 전송하도록 수정됨 + const handleGrade = (qIndex: number) => { + if (isGraded[qIndex]) return; - // 퀴즈 핸들러 - const handleSelect = (q, o) => setSelected(prev => ({ ...prev, [q]: o })); - const handleGrade = (qIndex) => { - setIsGraded(prev => { - const updated = [...prev]; - updated[qIndex] = true; - return updated; + // 1. UI 상태 업데이트 + const updatedGraded = [...isGraded]; + updatedGraded[qIndex] = true; + setIsGraded(updatedGraded); + setIsOpen((prev: any) => ({ ...prev, [qIndex]: true })); + + // 2. 현재까지의 정답 여부 계산 + const gptData = article.gpt_result || article.gptResult || article.gptResults || article.GPTResult || {}; + const quizzes = article.quizzes || gptData.quizzes || []; + + // 전체 문제에 대한 O/X 배열 생성 + const results = quizzes.map((quiz: any, idx: number) => { + const userAnswer = selected[idx]; + const correctAnswer = getCorrectIndex(quiz); + // 아직 안 푼 문제는 false로 처리하거나, 현재 푼 문제까지만 계산 + return userAnswer === correctAnswer; }); - setIsOpen(prev => ({ ...prev, [qIndex]: true })); + + // 3. 서버로 전송 (하나만 풀어도 저장해서 오답노트에 남기기 위함) + submitQuizResult(results); + }; + + if (loading) return ( + + ); + + if (!article) return null; + + const gptData = article.gpt_result || article.gptResult || article.gptResults || article.GPTResult || {}; + const keywordsData = gptData.keywords || article.keywords || []; + const quizData = article.quizzes || gptData.quizzes || []; + + const renderContent = () => { + switch (activeTab) { + case "기사": + return ; + case "키워드": + return ; + case "퀴즈": + return ( + setSelected((prev: any) => ({ ...prev, [q]: o }))} + handleGrade={handleGrade} + toggleOpen={(qIndex: number) => setIsOpen((prev: any) => ({ ...prev, [qIndex]: !prev[qIndex] }))} + myQuizResults={myQuizResults} + /> + ); + default: + return null; + } }; - const toggleOpen = (qIndex) => setIsOpen(prev => ({ ...prev, [qIndex]: !prev[qIndex] })); return ( - - router.push("/")} /> - - {activeTab === "기사" && ( - setActiveTab("퀴즈")} - /> - )} - - {activeTab === "요약" && } - - {activeTab === "키워드" && } - - {activeTab === "퀴즈" && ( - - )} + + { + if (tab === "퀴즈") handleMoveToQuiz(); + else setActiveTab(tab); + }} + onBack={() => { + if (router.canGoBack()) router.back(); + else router.replace("/"); + }} + /> + + {renderContent()} + ); -} \ No newline at end of file +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: '#fff' }, + content: { flex: 1, backgroundColor: '#fff' }, + center: { flex: 1, justifyContent: "center", alignItems: "center" } +}); \ No newline at end of file diff --git a/app/article/components/ArticleTab.tsx b/app/article/components/ArticleTab.tsx index 7cc1b6a..7ee7527 100644 --- a/app/article/components/ArticleTab.tsx +++ b/app/article/components/ArticleTab.tsx @@ -1,347 +1,123 @@ // oba_fronted/app/article/components/ArticleTab.tsx +import { View, Text, ScrollView, StyleSheet, Image, useWindowDimensions, TouchableOpacity } from "react-native"; +import React, { useState } from "react"; -// oba_fronted/app/article/components/ArticleTab.tsx - -import { useState, useEffect } from "react"; -import { View, Text, ScrollView, StyleSheet, Platform, Image, TouchableOpacity } from "react-native"; - -// [이미지 경로] assets/icons 폴더가 프로젝트 최상위에 위치 -const iconExpanded = require("../../../assets/icons/toggle_1.png"); -const iconCollapsed = require("../../../assets/icons/toggle_2.png"); - -// 1. 이미지 비율 자동 조절 컴포넌트 -const AutoHeightImage = ({ uri }) => { - const [aspectRatio, setAspectRatio] = useState(1.5); // 기본비율 - - useEffect(() => { - if (uri) { - Image.getSize( - uri, - (width, height) => { - if (width > 0 && height > 0) { - setAspectRatio(width / height); - } - }, - (error) => { - console.log("Image size load failed:", error); - } - ); - } - }, [uri]); - - return ( - - - - ); -}; - -export default function ArticleTab({ article, onMoveToQuiz }) { - // 1. 토글 상태 관리 - const [isExpanded, setIsExpanded] = useState(false); +interface ArticleTabProps { + article: any; + onMoveToQuiz: () => void; +} - // 2. 데이터 안전장치 (백엔드 DTO 필드에 맞게 수정) - const categoryList = article.keywords || []; // ← keywords 사용 - const summaryText = article.summary || null; - const subtitles = article.subtitle || []; - const contents = article.content || []; +export default function ArticleTab({ article, onMoveToQuiz }: ArticleTabProps) { + const { width } = useWindowDimensions(); + const [showSummary, setShowSummary] = useState(false); - /** - * 본문 아이템 렌더링 함수 - */ - const renderContentItem = (line, index, olIndex = 0) => { - const key = `content-${index}`; + // 1. 본문 데이터 + const contentList = article.content || []; - if (typeof line !== "string") { - return ( - - {String(line)} - - ); - } + // 2. GPT 데이터 (스네이크 케이스 우선 확인) + const gptData = article.gpt_result || article.gptResult || {}; - // 1. 이미지 처리 (URL) - if (line.startsWith("")) { - const imageUrl = line.replace("", "").trim(); - return ; - } + // 3. 태그(키워드) 추출 로직 + const rawKeywords = gptData.keywords || article.keywords || []; + + const tags = Array.isArray(rawKeywords) && rawKeywords.length > 0 + ? rawKeywords.map((k: any) => typeof k === 'string' ? k : k.keyword).slice(0, 3) + : ["IT/과학", "트렌드"]; - // 2. 순서 없는 리스트 (
    ) - if (line.startsWith("
      ")) { - const listText = line.replace("
        ", "").trim(); - return ( - - - {listText} - - ); - } - - // 3. 순서 있는 리스트 (
          ) - if (line.startsWith("
            ")) { - const listText = line.replace("
              ", "").trim(); - return ( - - {olIndex}. - {listText} - - ); - } - - // 4. 일반 텍스트 - return ( - - {line} - - ); - }; + // 4. 요약문 추출 로직 + const summaryText = gptData.summary || article.summary || + (article.summaryBullets ? article.summaryBullets.join("\n") : "요약 내용이 없습니다."); return ( - - {/* --- 헤더 --- */} - - - - {categoryList.map((cat, idx) => ( - - {cat} - - ))} - - - - {article.title} - {/* ← 메타 정보: publishTime · servingDate 로 교체 */} - - {article.publishTime} · {article.servingDate} - + + + {/* 1. 태그 영역 */} + + {tags.map((tag: string, i: number) => ( + + {tag} + + ))} - {/* --- AI 요약 --- */} - {summaryText && ( - - setIsExpanded(!isExpanded)} - style={styles.aiHeader} - > - - {isExpanded ? "AI 요약 접기" : "AI 요약 보기"} - - - + {/* 2. 제목 & 날짜 */} + {article.title} + 한입경제 · {article.servingDate || "2026.01.14"} + + {/* 3. AI 요약 버튼 */} + setShowSummary(!showSummary)} + > + + {showSummary ? "AI 요약 접기" : "AI 요약 보기"} + + 🤖 + - {isExpanded && ( - - {summaryText} - - )} + {/* 요약 내용 */} + {showSummary && ( + + {summaryText} )} - {/* --- 본문 영역 (카드 스타일 적용) --- */} - - {contents.map((sectionLines, index) => { - const subTitle = subtitles[index]; - const showSubTitle = subTitle && subTitle !== "nosubtitle"; - - //
                번호 계산 로직 - let olCounter = 0; - + {/* 4. 구분선 */} + + + {/* 5. 본문 렌더링 */} + + {contentList.map((text: string, index: number) => { + if (text.startsWith("")) { + const imageUrl = text.replace("", "").trim(); + return ( + + ); + } return ( - - {/* 소제목 */} - {showSubTitle && {subTitle}} - - {/* 본문 내용 매핑 */} - {Array.isArray(sectionLines) ? ( - sectionLines.map((line, lineIdx) => { - if (typeof line === "string" && line.startsWith("
                  ")) { - olCounter += 1; - return renderContentItem(line, lineIdx, olCounter); - } else { - olCounter = 0; - return renderContentItem(line, lineIdx); - } - }) - ) : ( - {sectionLines} - )} - + + {text} + ); })} - - {/* --- 퀴즈 풀러 가기 버튼 --- */} - - - 퀴즈 풀러 가기 → - - - + {/* 하단 퀴즈 이동 버튼 */} + + + 퀴즈 풀러 가기 👉 + + ); } const styles = StyleSheet.create({ - scrollContainer: { - backgroundColor: "#F5F6F8", - flexGrow: 1, - }, - - // --- 헤더 스타일 --- - headerContainer: { - paddingHorizontal: 20, - paddingTop: 24, - paddingBottom: 20, - }, - categoryWrapper: { marginBottom: 10 }, - categoryChip: { - backgroundColor: "rgba(0,0,0,0.05)", - paddingHorizontal: 10, - paddingVertical: 5, - borderRadius: 6, - marginRight: 8, - }, - categoryText: { color: "#555", fontSize: 13, fontWeight: "600" }, - title: { fontSize: 22, fontWeight: "bold", color: "#191F28", lineHeight: 30, marginBottom: 6 }, - meta: { fontSize: 13, color: "#8B95A1" }, - - // --- AI 요약 카드 스타일 --- - aiCard: { - backgroundColor: "#FFFFFF", - marginHorizontal: 16, - marginBottom: 24, - borderRadius: 16, - overflow: "hidden", - ...Platform.select({ - ios: { shadowColor: "#000", shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.03, shadowRadius: 8 }, - android: { elevation: 2 }, - }), - }, - aiHeader: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", padding: 16, backgroundColor: "#F9FAFB" }, - aiTitle: { fontSize: 15, fontWeight: "700", color: "#4B6EF5" }, - toggleIcon: { width: 35, height: 35 }, - aiBody: { padding: 16, paddingTop: 0, backgroundColor: "#F9FAFB" }, - aiText: { fontSize: 14, lineHeight: 22, color: "#333D4B" }, - - // --- 본문 카드 스타일 --- - contentCard: { - backgroundColor: "#FFFFFF", - marginHorizontal: 16, - borderRadius: 20, - padding: 24, - ...Platform.select({ - ios: { - shadowColor: "#000", - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.05, - shadowRadius: 10, - }, - android: { - elevation: 3, - }, - }), - }, - - sectionBlock: { marginBottom: 24 }, - - // 1. 소제목 - sectionTitle: { - fontSize: 20, - fontWeight: "700", - color: "#191F28", - marginBottom: 12, - marginTop: 8, - }, - - // 2. 리스트 아이템 - listItemContainer: { - flexDirection: "row", - marginBottom: 8, - paddingLeft: 4, - }, - bulletPoint: { - fontSize: 15, - lineHeight: 26, - color: "#333D4B", - marginRight: 8, - fontWeight: "bold", - }, - numberPoint: { - fontSize: 15, - lineHeight: 26, - color: "#333D4B", - marginRight: 8, - fontWeight: "bold", - }, - listItemText: { - flex: 1, - fontSize: 15, - lineHeight: 26, - color: "#333D4B", - fontWeight: "500", - }, - - // 3. 본문 텍스트 - content: { - fontSize: 16, - lineHeight: 26, - color: "#333D4B", - letterSpacing: -0.2, - marginBottom: 12, - }, - - // 이미지 스타일 - imageContainer: { - marginVertical: 12, - alignItems: "center", - borderRadius: 8, - overflow: "hidden", - width: "100%", - }, - contentImage: { - width: "100%", - backgroundColor: "#eee", - }, - - // --- 퀴즈 버튼 스타일 --- - buttonContainer: { - paddingHorizontal: 7, - marginTop: 5, - }, - quizButton: { - backgroundColor: "#4B6EF5", - paddingVertical: 12, - borderRadius: 10, - alignItems: "center", - justifyContent: "center", - ...Platform.select({ - ios: { - shadowColor: "#4B6EF5", - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.3, - shadowRadius: 8, - }, - android: { elevation: 4 }, - }), - }, - quizButtonText: { - color: "#FFFFFF", - fontSize: 14, - fontWeight: "700", - }, -}); + container: { padding: 20, paddingBottom: 50, backgroundColor: "#fff" }, + tagRow: { flexDirection: "row", gap: 8, marginBottom: 12 }, + tagBadge: { backgroundColor: "#F2F4F6", paddingHorizontal: 10, paddingVertical: 6, borderRadius: 6 }, + tagText: { fontSize: 13, color: "#4E5968", fontWeight: "600" }, + title: { fontSize: 24, fontWeight: "bold", color: "#191F28", lineHeight: 34, marginBottom: 10 }, + dateInfo: { fontSize: 14, color: "#8B95A1", marginBottom: 24 }, + aiSummaryBtn: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", backgroundColor: "#F9FAFB", paddingVertical: 14, paddingHorizontal: 20, borderRadius: 12, marginBottom: 10, borderWidth: 1, borderColor: "#E5E8EB" }, + aiSummaryText: { fontSize: 16, fontWeight: "700", color: "#4A8CFF" }, + summaryBox: { backgroundColor: "#F0F7FF", padding: 20, borderRadius: 12, marginBottom: 20 }, + summaryContent: { fontSize: 15, lineHeight: 24, color: "#333" }, + divider: { height: 1, backgroundColor: "#F2F4F6", marginVertical: 20 }, + contentContainer: { marginBottom: 30 }, + bodyText: { fontSize: 17, lineHeight: 28, color: "#333", marginBottom: 16 }, + footer: { alignItems: "center", marginTop: 20 }, + quizBtn: { backgroundColor: "#191F28", paddingHorizontal: 24, paddingVertical: 14, borderRadius: 30 }, + quizBtnText: { fontSize: 16, fontWeight: "bold", color: "#fff" }, +}); \ No newline at end of file diff --git a/app/article/components/KeywordTab.tsx b/app/article/components/KeywordTab.tsx index 2f91b17..8cb9109 100644 --- a/app/article/components/KeywordTab.tsx +++ b/app/article/components/KeywordTab.tsx @@ -1,56 +1,107 @@ // oba_fronted/app/article/components/KeywordTab.tsx +import { View, Text, ScrollView, StyleSheet } from "react-native"; +import React from "react"; -import { View, Text, StyleSheet, ScrollView, Image } from "react-native"; +export interface KeywordData { + keyword: string; + description?: string; + desc?: string; + definition?: string; +} + +interface KeywordTabProps { + keywords?: (string | KeywordData)[]; +} -// 🍕 pizza 이미지 4개 불러오기 -const pizzaImages = [ - require("../../../assets/pizza/comb.png"), - require("../../../assets/pizza/hwaa.png"), - require("../../../assets/pizza/mar.png"), - require("../../../assets/pizza/pep.png"), -]; +export default function KeywordTab({ keywords }: KeywordTabProps) { + const safeKeywords = keywords || []; + + if (!safeKeywords || safeKeywords.length === 0) { + return null; + } -export default function KeywordTab({ keywords }) { return ( - - {keywords.map((word, index) => ( - - {word} - - ))} + + {safeKeywords.map((item, index) => { + // 1. 키워드 추출 + const keyword = typeof item === 'string' ? item : item.keyword; + + // 2. 설명 추출 (description, desc, definition 등 다 찾아봄) + let description = null; + if (typeof item !== 'string') { + description = item.description || item.desc || item.definition || null; + } + + return ( + + + 🍕 + {keyword} + + + {/* 3. 설명 표시 (없으면 안내 문구) */} + + {description || "서버로부터 상세 설명 데이터를 받지 못했습니다."} + + + ); + })} ); } - const styles = StyleSheet.create({ - scrollView: { flex: 1 }, - container: { padding: 20, paddingBottom: 40 }, - keywordBox: { - marginBottom: 18, - padding: 16, - backgroundColor: "#fff", - borderRadius: 14, + scrollView: { + flex: 1, + backgroundColor: "#F8F9FA", + }, + container: { + padding: 20, + paddingBottom: 60, + }, + keywordCard: { + backgroundColor: "#FFFFFF", + borderRadius: 16, + padding: 20, + marginBottom: 16, shadowColor: "#000", - shadowOpacity: 0.07, - shadowRadius: 6, - shadowOffset: { width: 0, height: 3 }, - elevation: 3, + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.05, + shadowRadius: 8, + elevation: 2, + borderWidth: 1, + borderColor: "#F0F0F0", }, - row: { + header: { flexDirection: "row", alignItems: "center", - marginBottom: 3, - marginLeft: 5, + marginBottom: 8, + }, + icon: { + fontSize: 20, + marginRight: 8, }, - word: { - fontSize: 16, + keywordTitle: { + fontSize: 17, fontWeight: "700", - color: "#333", + color: "#191F28", }, - pizzaImg: { - width: 23, - height: 23, - marginRight: 6, + keywordDesc: { + fontSize: 14, + lineHeight: 22, + color: "#4E5968", + marginTop: 4, }, -}); + noDataText: { + color: "#999", + fontStyle: "italic", + fontSize: 13, + } +}); \ No newline at end of file diff --git a/app/article/components/QuizTab.tsx b/app/article/components/QuizTab.tsx index 945c5d3..cf40896 100644 --- a/app/article/components/QuizTab.tsx +++ b/app/article/components/QuizTab.tsx @@ -1,423 +1,117 @@ // oba_fronted/app/article/components/QuizTab.tsx +import React, { useState, useEffect } from "react" +import { ScrollView, View, Text, TouchableOpacity, StyleSheet, Image, Modal } from "react-native" -"use client" - -import { useState, useEffect, useRef } from "react" -import { ScrollView, View, Text, TouchableOpacity, StyleSheet, Image, Modal, Animated, Easing } from "react-native" - -export default function QuizTab({ quizList, selected, isGraded, isOpen, handleSelect, handleGrade, toggleOpen }) { +export default function QuizTab({ quizList, selected, isGraded, isOpen, handleSelect, handleGrade, toggleOpen, myQuizResults }: any) { + const safeQuizList = quizList || [] + const safeMyResults = myQuizResults || [] const [showResult, setShowResult] = useState(false) - const confettiAnims = useRef( - Array.from({ length: 150 }, () => ({ - translateY: new Animated.Value(0), - translateX: new Animated.Value(0), - rotate: new Animated.Value(0), - opacity: new Animated.Value(0), - scale: new Animated.Value(0), - })), - ).current - - const sparkleAnims = useRef( - Array.from({ length: 30 }, () => ({ - scale: new Animated.Value(0), - opacity: new Animated.Value(0), - rotate: new Animated.Value(0), - })), - ).current - - const burstRings = useRef( - Array.from({ length: 3 }, () => ({ - scale: new Animated.Value(0), - opacity: new Animated.Value(0), - })), - ).current - - const pizzaConfettiAnims = useRef( - Array.from({ length: 40 }, () => ({ - translateY: new Animated.Value(0), - translateX: new Animated.Value(0), - rotate: new Animated.Value(0), - opacity: new Animated.Value(0), - scale: new Animated.Value(0), - })), - ).current - - const pizzaImages = [ - require("../../../assets/pizza/comb.png"), - require("../../../assets/pizza/hwaa.png"), - require("../../../assets/pizza/mar.png"), - require("../../../assets/pizza/pep.png"), - ] + const totalCount = safeQuizList.length + const safeIsGraded = isGraded || [] + const gradedCount = safeIsGraded.filter((graded: any) => graded === true).length + + const getCorrectIndex = (quiz: any) => { + return quiz.answerIndex !== undefined + ? Number(quiz.answerIndex) + : (parseInt(quiz.answer?.replace(/[^0-9]/g, "") || "0") - 1); + } - const totalCount = quizList.length - const gradedCount = isGraded.filter((graded) => graded === true).length - const correctCount = quizList.reduce((acc, quiz, index) => { + const correctCount = safeQuizList.reduce((acc: number, quiz: any, index: number) => { const userAnswer = selected[index] - return userAnswer === quiz.answer ? acc + 1 : acc + const correctAnswer = getCorrectIndex(quiz); + return userAnswer === correctAnswer ? acc + 1 : acc }, 0) - const triggerConfetti = () => { - confettiAnims.forEach((anim) => { - anim.translateY.setValue(0) - anim.translateX.setValue(0) - anim.rotate.setValue(0) - anim.opacity.setValue(0) - anim.scale.setValue(0) - }) - - sparkleAnims.forEach((anim) => { - anim.scale.setValue(0) - anim.opacity.setValue(0) - anim.rotate.setValue(0) - }) - - burstRings.forEach((anim) => { - anim.scale.setValue(0) - anim.opacity.setValue(0) - }) - - const ringAnimations = burstRings.map((anim, index) => - Animated.sequence([ - Animated.delay(index * 80), - Animated.parallel([ - Animated.timing(anim.scale, { - toValue: 3 + index * 0.5, - duration: 1000, - easing: Easing.out(Easing.cubic), - useNativeDriver: true, - }), - Animated.sequence([ - Animated.timing(anim.opacity, { - toValue: 0.6, - duration: 100, - useNativeDriver: true, - }), - Animated.timing(anim.opacity, { - toValue: 0, - duration: 900, - useNativeDriver: true, - }), - ]), - ]), - ]), - ) - - const sparkleAnimations = sparkleAnims.map((anim, index) => { - const angle = (index / sparkleAnims.length) * Math.PI * 2 - // const distance = 100 + Math.random() * 80 - - return Animated.sequence([ - Animated.delay(Math.random() * 200), - Animated.parallel([ - Animated.spring(anim.scale, { - toValue: 1 + Math.random() * 0.5, - friction: 4, - tension: 50, - useNativeDriver: true, - }), - Animated.sequence([ - Animated.timing(anim.opacity, { - toValue: 1, - duration: 150, - useNativeDriver: true, - }), - Animated.loop( - Animated.sequence([ - Animated.timing(anim.opacity, { - toValue: 0.3, - duration: 400, - useNativeDriver: true, - }), - Animated.timing(anim.opacity, { - toValue: 1, - duration: 400, - useNativeDriver: true, - }), - ]), - { iterations: 2 }, - ), - Animated.timing(anim.opacity, { - toValue: 0, - duration: 300, - useNativeDriver: true, - }), - ]), - Animated.timing(anim.rotate, { - toValue: 360 + Math.random() * 360, - duration: 2000, - easing: Easing.out(Easing.cubic), - useNativeDriver: true, - }), - ]), - ]) - }) - - const confettiAnimations = confettiAnims.map((anim, index) => { - const patterns = 6 - const pattern = index % patterns - let finalX, finalY, duration, delay - - if (pattern === 0) { - // Circular explosion - const angle = (index / 25) * Math.PI * 2 - const distance = 180 + Math.random() * 150 - finalX = Math.cos(angle) * distance - finalY = Math.sin(angle) * distance - duration = 1000 + Math.random() * 500 - delay = 0 - } else if (pattern === 1) { - // Fountain upward - finalX = (Math.random() - 0.5) * 250 - finalY = -250 - Math.random() * 200 - duration = 1200 + Math.random() * 600 - delay = 50 - } else if (pattern === 2) { - // Spiral - const angle = (index / 25) * Math.PI * 6 - const distance = (index % 25) * 12 + 100 - finalX = Math.cos(angle) * distance - finalY = Math.sin(angle) * distance - duration = 1100 + Math.random() * 500 - delay = (index % 25) * 15 - } else if (pattern === 3) { - // Wide random burst - finalX = (Math.random() - 0.5) * 500 - finalY = (Math.random() - 0.5) * 500 - duration = 800 + Math.random() * 600 - delay = Math.random() * 200 - } else if (pattern === 4) { - // Diagonal streaks - const direction = Math.random() > 0.5 ? 1 : -1 - finalX = direction * (200 + Math.random() * 200) - finalY = -150 - Math.random() * 150 - duration = 1000 + Math.random() * 400 - delay = Math.random() * 250 - } else { - // Radial burst outward - const angle = Math.random() * Math.PI * 2 - const distance = 150 + Math.random() * 180 - finalX = Math.cos(angle) * distance - finalY = Math.sin(angle) * distance - duration = 900 + Math.random() * 500 - delay = Math.random() * 150 - } - - const randomRotation = Math.random() * 3600 - 1800 - - return Animated.sequence([ - Animated.delay(delay), - Animated.parallel([ - Animated.spring(anim.scale, { - toValue: 1.5 + Math.random() * 0.8, - friction: 2.5, - tension: 50, - useNativeDriver: true, - }), - Animated.timing(anim.opacity, { - toValue: 1, - duration: 150, - useNativeDriver: true, - }), - Animated.timing(anim.translateX, { - toValue: finalX, - duration: duration, - easing: Easing.out(Easing.cubic), - useNativeDriver: true, - }), - Animated.timing(anim.translateY, { - toValue: finalY, - duration: duration, - easing: Easing.out(Easing.quad), - useNativeDriver: true, - }), - Animated.timing(anim.rotate, { - toValue: randomRotation, - duration: duration, - easing: Easing.linear, - useNativeDriver: true, - }), - ]), - Animated.timing(anim.opacity, { - toValue: 0, - duration: 400, - useNativeDriver: true, - }), - ]) - }) - - Animated.parallel([...ringAnimations, ...sparkleAnimations, ...confettiAnimations]).start() - } - - const triggerPizzaConfetti = () => { - pizzaConfettiAnims.forEach((anim) => { - anim.translateY.setValue(0) - anim.translateX.setValue(0) - anim.rotate.setValue(0) - anim.opacity.setValue(0) - anim.scale.setValue(0) - }) - - const pizzaAnimations = pizzaConfettiAnims.map((anim, index) => { - const angle = (index / pizzaConfettiAnims.length) * Math.PI * 2 - const distance = 150 + Math.random() * 120 - const finalX = Math.cos(angle) * distance - const finalY = Math.sin(angle) * distance - 50 - const duration = 1200 + Math.random() * 400 - const randomRotation = Math.random() * 720 - 360 - - return Animated.sequence([ - Animated.delay(Math.random() * 150), - Animated.parallel([ - Animated.spring(anim.scale, { - toValue: 1, - friction: 3, - tension: 40, - useNativeDriver: true, - }), - Animated.timing(anim.opacity, { - toValue: 1, - duration: 100, - useNativeDriver: true, - }), - Animated.timing(anim.translateX, { - toValue: finalX, - duration: duration, - easing: Easing.out(Easing.cubic), - useNativeDriver: true, - }), - Animated.timing(anim.translateY, { - toValue: finalY, - duration: duration, - easing: Easing.bezier(0.33, 1, 0.68, 1), - useNativeDriver: true, - }), - Animated.timing(anim.rotate, { - toValue: randomRotation, - duration: duration, - easing: Easing.linear, - useNativeDriver: true, - }), - ]), - Animated.timing(anim.opacity, { - toValue: 0, - duration: 300, - useNativeDriver: true, - }), - ]) - }) - - Animated.parallel(pizzaAnimations).start() - } - useEffect(() => { if (totalCount > 0 && gradedCount === totalCount) { setShowResult(true) - if (correctCount === totalCount) { - triggerPizzaConfetti() - } } }, [gradedCount, totalCount]) - const getResultContent = () => { - if (correctCount === totalCount) { - return { - emoji: "🏆", - title: "완벽해요!", - desc: "모든 문제를 맞히셨네요!\n이건 피자 한 판 드실 자격이 있습니다!! 🍕🎉", - } - } else if (correctCount >= totalCount / 2) { - return { - emoji: "👏", - title: "잘했어요!", - desc: `${totalCount}문제 중 ${correctCount}개를 맞혔어요.\n거의 다 왔어요, 피자 냄새가 나기 시작해요! 🔥🍕`, - } - } else { - return { - emoji: "😓", - title: "아쉬워요", - desc: `${totalCount}문제 중 ${correctCount}개를 맞혔어요.\n다음엔 따끈한 피자에 더 가까워질 거예요! 🍕😊`, - } - } - } + const resultContent = correctCount === totalCount + ? { emoji: "🏆", title: "완벽해요!", desc: "모든 문제를 맞히셨네요!\n피자 한 판 드실 자격이 있습니다!! 🍕🎉" } + : { emoji: "👏", title: "학습 완료!", desc: `${totalCount}문제 중 ${correctCount}개를 맞혔어요.\n오늘도 지식 한 조각 챙겨가세요! 🍕` }; - const resultContent = getResultContent() + if (totalCount === 0) return 등록된 퀴즈가 없습니다. return ( Quiz - - {quizList.map((quiz, qIndex) => { + {safeQuizList.map((quiz: any, qIndex: number) => { const userAnswer = selected[qIndex] - const graded = isGraded[qIndex] + const graded = safeIsGraded[qIndex] const open = isOpen[qIndex] - const isCorrect = userAnswer === quiz.answer + const correctAnswerIndex = getCorrectIndex(quiz); + const isCorrect = userAnswer === correctAnswerIndex; + const isPreviouslySolved = safeMyResults[qIndex] === true return ( - + {`Q${qIndex + 1}. ${quiz.question}`} - {quiz.options.map((opt, oIndex) => { - const selectedOption = userAnswer === oIndex + {quiz.options.map((opt: string, oIndex: number) => { + const isSelected = userAnswer === oIndex + const isRightAnswer = oIndex === correctAnswerIndex + + // 채점 후 스타일 결정 로직 + let optionStyle: any = [styles.option]; + let textStyle: any = [styles.optionText]; + + if (isSelected) optionStyle.push(styles.selected); + + if (graded || isPreviouslySolved) { + if (isRightAnswer) { + // 정답인 경우 (사용자 선택 여부와 상관없이 초록색 표시) + optionStyle.push(styles.correctOption); + textStyle.push(styles.correctText); + } else if (isSelected && !isRightAnswer) { + // 오답인데 사용자가 선택한 경우 (빨간색 표시) + optionStyle.push(styles.wrongOption); + textStyle.push(styles.wrongText); + } + } return ( handleSelect(qIndex, oIndex)} - style={[ - styles.option, - selectedOption && styles.selected, - graded && - oIndex === quiz.answer && { - backgroundColor: "#DFF5CC", - borderColor: "#8BC34A", - }, - graded && - selectedOption && - oIndex !== quiz.answer && { - backgroundColor: "#FDDCDC", - borderColor: "#E57373", - }, - ]} + style={optionStyle} > - {opt} + + {opt} + { (graded || isPreviouslySolved) && isRightAnswer && } + ) })} - handleGrade(qIndex)} - disabled={graded || selected[qIndex] === undefined} - style={[ - styles.gradeBtn, - graded && styles.disabledBtn, - selected[qIndex] === undefined && styles.disabledBtn, - ]} - > - {graded ? "채점 완료" : "채점하기"} - + {!isPreviouslySolved && ( + handleGrade(qIndex)} + disabled={graded || selected[qIndex] === undefined} + style={[styles.gradeBtn, (graded || selected[qIndex] === undefined) && styles.disabledBtn]} + > + {graded ? "채점 완료" : "채점하기"} + + )} - {graded && ( + {(graded || isPreviouslySolved) && ( toggleOpen(qIndex)}> - {open && ( - - {isCorrect ? "🎉 정답입니다!" : "❌ 오답입니다!"} + + {isCorrect || isPreviouslySolved ? "🎉 정답입니다!" : "❌ 오답입니다!"} - {quiz.explanation} )} @@ -430,163 +124,10 @@ export default function QuizTab({ quizList, selected, isGraded, isOpen, handleSe setShowResult(false)}> - {correctCount === totalCount && - pizzaConfettiAnims.map((anim, index) => { - const pizzaImage = pizzaImages[index % pizzaImages.length] - - return ( - - - - ) - })} - - {correctCount === totalCount && - burstRings.map((anim, index) => ( - - ))} - - {correctCount === totalCount && - sparkleAnims.map((anim, index) => { - const angle = (index / sparkleAnims.length) * Math.PI * 2 - const distance = 100 + Math.random() * 80 - const left = `${50 + Math.cos(angle) * distance}%` - const top = `${50 + Math.sin(angle) * distance}%` - - return ( - - - - ) - })} - - {correctCount === totalCount && - confettiAnims.map((anim, index) => { - const colors = [ - "#FFD700", - "#FF1744", - "#00E5FF", - "#76FF03", - "#FF6F00", - "#E91E63", - "#9C27B0", - "#3F51B5", - "#00BCD4", - "#FFEB3B", - "#FF5722", - "#8BC34A", - "#FFC107", - "#00ACC1", - "#D500F9", - "#FF4081", - "#1DE9B6", - "#FF3D00", - "#EEFF41", - "#FF80AB", - ] - const confettiColor = colors[index % colors.length] - const size = 10 + (index % 8) * 3 - const shapes = ["circle", "square", "star", "diamond"] - const shape = shapes[index % 4] - let borderRadius = 0 - - if (shape === "circle") borderRadius = size / 2 - else if (shape === "square") borderRadius = 2 - else if (shape === "diamond") borderRadius = 0 - - return ( - - ) - })} - - + {resultContent.emoji} {resultContent.title} - - 총 {correctCount}개 / {totalCount}개 - {resultContent.desc} - setShowResult(false)}> 확인 @@ -598,14 +139,12 @@ export default function QuizTab({ quizList, selected, isGraded, isOpen, handleSe } const styles = StyleSheet.create({ - container: { padding: 20 }, header: { fontSize: 22, fontWeight: "700", textAlign: "center", marginBottom: 20, }, - quizBlock: { backgroundColor: "white", borderRadius: 14, @@ -616,9 +155,11 @@ const styles = StyleSheet.create({ shadowRadius: 4, elevation: 2, }, - + solvedBlock: { + borderColor: "#E8F5E9", + borderWidth: 1, + }, question: { fontSize: 16, fontWeight: "600", marginBottom: 14 }, - option: { padding: 12, borderWidth: 1, @@ -627,52 +168,66 @@ const styles = StyleSheet.create({ marginBottom: 8, backgroundColor: "#fff", }, - + optionContent: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center' + }, optionText: { fontSize: 14, color: "#333" }, - selected: { borderColor: "#ff9f00", backgroundColor: "#fff5e0", }, - + // ✅ 정답 스타일 + correctOption: { + backgroundColor: "#DFF5CC", + borderColor: "#8BC34A", + }, + correctText: { + color: "#2E7D32", + fontWeight: "700", + }, + // ✅ 오답 스타일 + wrongOption: { + backgroundColor: "#FDDCDC", + borderColor: "#E57373", + }, + wrongText: { + color: "#C62828", + }, + checkIcon: { + fontSize: 14, + }, gradeBtn: { marginTop: 10, backgroundColor: "#222", paddingVertical: 12, borderRadius: 8, }, - gradeText: { color: "#fff", textAlign: "center", fontWeight: "600", }, - disabledBtn: { backgroundColor: "#ccc" }, - explanationWrapper: { flexDirection: "row", alignItems: "flex-start", marginTop: 16, }, - pizzaIcon: { width: 36, height: 36, marginRight: 10, }, - explanationBox: { flex: 1, backgroundColor: "#f0f8ff", borderRadius: 10, padding: 12, }, - resultText: { fontWeight: "600", fontSize: 15, marginBottom: 4 }, - explanation: { fontSize: 14, color: "#555", lineHeight: 20 }, - modalOverlay: { flex: 1, backgroundColor: "rgba(0,0,0,0.5)", @@ -691,72 +246,10 @@ const styles = StyleSheet.create({ shadowRadius: 4, elevation: 5, }, - modalEmoji: { - fontSize: 50, - marginBottom: 10, - }, - modalTitle: { - fontSize: 24, - fontWeight: "bold", - marginBottom: 10, - color: "#333", - }, - modalScore: { - fontSize: 18, - fontWeight: "600", - marginBottom: 16, - color: "#555", - }, - modalDesc: { - fontSize: 15, - textAlign: "center", - color: "#666", - marginBottom: 24, - lineHeight: 22, - }, - modalCloseBtn: { - backgroundColor: "#ff9f00", - paddingVertical: 12, - paddingHorizontal: 30, - borderRadius: 25, - }, - modalCloseText: { - color: "white", - fontWeight: "bold", - fontSize: 16, - }, - confetti: { - position: "absolute", - top: "50%", - left: "50%", - }, - burstRing: { - position: "absolute", - top: "50%", - left: "50%", - width: 100, - height: 100, - marginLeft: -50, - marginTop: -50, - borderRadius: 50, - borderWidth: 3, - }, - sparkle: { - position: "absolute", - }, - sparkleText: { - fontSize: 24, - textShadowColor: "#FFD700", - textShadowOffset: { width: 0, height: 0 }, - textShadowRadius: 10, - }, - pizzaConfetti: { - position: "absolute", - top: "50%", - left: "50%", - }, - pizzaImage: { - width: 60, - height: 60, - }, + modalEmoji: { fontSize: 50, marginBottom: 10 }, + modalTitle: { fontSize: 24, fontWeight: "bold", marginBottom: 10, color: "#333" }, + modalDesc: { fontSize: 15, textAlign: "center", color: "#666", marginBottom: 24, lineHeight: 22 }, + modalCloseBtn: { backgroundColor: "#ff9f00", paddingVertical: 12, paddingHorizontal: 30, borderRadius: 25 }, + modalCloseText: { color: "white", fontWeight: "bold", fontSize: 16 }, + empty: { flex: 1, justifyContent: 'center', alignItems: 'center' } }) \ No newline at end of file diff --git a/app/article/components/TabBar.tsx b/app/article/components/TabBar.tsx index ec143f6..33d8203 100644 --- a/app/article/components/TabBar.tsx +++ b/app/article/components/TabBar.tsx @@ -1,224 +1,113 @@ -// import { SafeAreaView } from "react-native-safe-area-context"; -// import { BlurView } from "expo-blur"; -// import { View, Text, TouchableOpacity, StyleSheet } from "react-native"; - -// export default function TabBar({ activeTab, setActiveTab, goHome }) { -// return ( -// -// -// -// -// {"<"} -// - -// {["기사", "요약", "키워드", "퀴즈"].map((tab) => ( -// setActiveTab(tab)} -// style={styles.tabBtn} -// > -// -// {tab} -// -// -// ))} -// -// -// -// ); -// } - -// const styles = StyleSheet.create({ -// blurBar: { -// height: 48, // 블러는 상단바 높이만 -// flexDirection: "row", -// alignItems: "center", -// paddingHorizontal: 12, -// backgroundColor: "rgba(255,255,255,0.25)", -// overflow: "hidden", -// borderBottomWidth: StyleSheet.hairlineWidth, -// borderColor: "rgba(255,255,255,0.3)", -// }, - -// backBtn: { -// paddingRight: 10, -// }, -// backText: { -// fontSize: 20, -// fontWeight: "600", -// }, - -// tabBtn: { -// paddingHorizontal: 12, -// }, -// tabText: { -// fontSize: 15, -// color: "#888", -// }, -// tabTextActive: { -// color: "#222", -// fontWeight: "700", -// }, -// }); - // oba_fronted/app/article/components/TabBar.tsx - -// 제안: 아이폰 스타일의 슬라이딩 탭바 import { SafeAreaView } from "react-native-safe-area-context"; -import { BlurView } from "expo-blur"; import { View, Text, TouchableOpacity, StyleSheet, Platform } from "react-native"; +import { Ionicons } from "@expo/vector-icons"; -export default function TabBar({ activeTab, setActiveTab, goHome }) { - // '요약' 제거하고 3개 탭만 유지 +interface TabBarProps { + activeTab: string; + setActiveTab: (tab: string) => void; + onBack: () => void; +} + +export default function TabBar({ activeTab, setActiveTab, onBack }: TabBarProps) { const tabs = ["기사", "키워드", "퀴즈"]; return ( - {/* 배경에 블러 효과 적용 */} - - - - - {/* 1. 뒤로가기 버튼 */} - - - - - {/* 2. 탭 리스트 (캡슐 스타일) */} - - {tabs.map((tab) => { - const isActive = activeTab === tab; - return ( - setActiveTab(tab)} - style={[ - styles.tabBtn, - isActive && styles.tabBtnActive, // 활성 상태 스타일 - ]} - activeOpacity={0.8} - > - - {tab} - - - ); - })} - - - {/* 오른쪽 여백 밸런스 (뒤로가기 버튼만큼 공간 확보) */} - - + + + + {/* ⬅️ 뒤로가기 버튼 */} + + + + + {/* 탭 리스트 */} + + {tabs.map((tab) => { + const isActive = activeTab === tab; + return ( + setActiveTab(tab)} + style={[ + styles.tabBtn, + isActive && styles.tabBtnActive, + ]} + activeOpacity={0.7} + > + + {tab} + + + ); + })} - - + + {/* 오른쪽 여백 (균형 맞춤용) */} + + + + ); } const styles = StyleSheet.create({ container: { - // 상단 고정 등을 위해 필요 시 설정 - zIndex: 10, - }, - blurBar: { - // 전체 너비와 하단 경계선 - width: "100%", + backgroundColor: "#fff", borderBottomWidth: 1, - borderColor: "rgba(0,0,0,0.05)", // 아주 연한 경계선 + borderBottomColor: "#F2F4F6", + zIndex: 10, }, safeArea: { - backgroundColor: "transparent", + backgroundColor: "#fff", }, - innerContainer: { - height: 54, // 탭바 높이 살짝 증가 (터치하기 편하게) + headerRow: { + height: 50, flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingHorizontal: 16, }, - - // --- 뒤로가기 버튼 --- backBtn: { width: 40, height: 40, justifyContent: "center", - alignItems: "flex-start", // 왼쪽 정렬 + alignItems: "flex-start", }, - backText: { - fontSize: 26, // 화살표 크기 키움 - fontWeight: "400", - color: "#191F28", - marginTop: -4, // 폰트 특성상 수직 중앙 맞춤 보정 - }, - - // --- 탭 그룹 --- - tabGroup: { + tabContainer: { flexDirection: "row", - gap: 4, // 탭 사이 간격 - backgroundColor: "#F2F4F6", // 탭 그룹 전체 배경 (연한 회색 파이프) + backgroundColor: "#F7F8FA", + borderRadius: 20, padding: 4, - borderRadius: 25, // 둥근 모서리 }, - - // --- 개별 탭 버튼 --- tabBtn: { paddingVertical: 6, - paddingHorizontal: 14, - borderRadius: 20, // 캡슐 모양 + paddingHorizontal: 16, + borderRadius: 16, }, - // [Active] 선택되었을 때 스타일 (검은색 캡슐) tabBtnActive: { - backgroundColor: "#FFFFFF", // 흰색 배경 (혹은 검정색 #191F28) - // 그림자 효과 + backgroundColor: "#fff", ...Platform.select({ - ios: { - shadowColor: "#000", - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.1, - shadowRadius: 2, - }, - android: { elevation: 1 }, + ios: { shadowColor: "#000", shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.08, shadowRadius: 2 }, + android: { elevation: 2 }, }), }, - - // --- 탭 텍스트 --- tabText: { fontSize: 14, fontWeight: "500", - color: "#8B95A1", // 비활성: 연한 회색 + color: "#8B95A1", }, - // [Active] 선택되었을 때 텍스트 tabTextActive: { - color: "#191F28", // 활성: 진한 회색/검정 + color: "#191F28", fontWeight: "700", }, - - // 레이아웃 균형용 더미 - dummySpace: { - width: 40, + dummy: { + width: 40, }, }); \ No newline at end of file diff --git a/app/components/PizzaMenu/usePizzaAnimation.ts b/app/components/PizzaMenu/usePizzaAnimation.ts index e8a6714..035f568 100644 --- a/app/components/PizzaMenu/usePizzaAnimation.ts +++ b/app/components/PizzaMenu/usePizzaAnimation.ts @@ -1,3 +1,5 @@ +// app/components/PizzaMenu/UsePizzaAnimation.ts + import { useRef, useState } from "react"; import { Animated, useWindowDimensions } from "react-native"; diff --git a/app/index.tsx b/app/index.tsx new file mode 100644 index 0000000..e69de29 diff --git a/package-lock.json b/package-lock.json index 775feb2..acdc426 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "@expo/vector-icons": "^15.0.3", + "@react-native-async-storage/async-storage": "2.2.0", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", @@ -19,13 +20,14 @@ "expo-font": "~14.0.9", "expo-haptics": "~15.0.7", "expo-image": "~3.0.10", - "expo-linking": "~8.0.8", + "expo-linking": "~8.0.11", "expo-router": "~6.0.13", + "expo-secure-store": "~15.0.8", "expo-splash-screen": "~31.0.10", "expo-status-bar": "~3.0.8", "expo-symbols": "~1.0.7", "expo-system-ui": "~6.0.8", - "expo-web-browser": "~15.0.8", + "expo-web-browser": "~15.0.10", "react": "^19.1.0", "react-dom": "19.1.0", "react-native": "^0.81.5", @@ -3322,6 +3324,18 @@ } } }, + "node_modules/@react-native-async-storage/async-storage": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz", + "integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==", + "license": "MIT", + "dependencies": { + "merge-options": "^3.0.4" + }, + "peerDependencies": { + "react-native": "^0.0.0-0 || >=0.65 <1.0" + } + }, "node_modules/@react-native/assets-registry": { "version": "0.81.5", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz", @@ -6751,13 +6765,13 @@ } }, "node_modules/expo-linking": { - "version": "8.0.8", - "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.8.tgz", - "integrity": "sha512-MyeMcbFDKhXh4sDD1EHwd0uxFQNAc6VCrwBkNvvvufUsTYFq3glTA9Y8a+x78CPpjNqwNAamu74yIaIz7IEJyg==", + "version": "8.0.11", + "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.11.tgz", + "integrity": "sha512-+VSaNL5om3kOp/SSKO5qe6cFgfSIWnnQDSbA7XLs3ECkYzXRquk5unxNS3pg7eK5kNUmQ4kgLI7MhTggAEUBLA==", "license": "MIT", "peer": true, "dependencies": { - "expo-constants": "~18.0.8", + "expo-constants": "~18.0.12", "invariant": "^2.2.4" }, "peerDependencies": { @@ -7047,6 +7061,15 @@ "node": ">=10" } }, + "node_modules/expo-secure-store": { + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-15.0.8.tgz", + "integrity": "sha512-lHnzvRajBu4u+P99+0GEMijQMFCOYpWRO4dWsXSuMt77+THPIGjzNvVKrGSl6mMrLsfVaKL8BpwYZLGlgA+zAw==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-server": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.5.tgz", @@ -7115,9 +7138,9 @@ } }, "node_modules/expo-web-browser": { - "version": "15.0.8", - "resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.8.tgz", - "integrity": "sha512-gn+Y2ABQr6/EvFN/XSjTuzwsSPLU1vNVVV0wNe4xXkcSnYGdHxt9kHxs9uLfoCyPByoaGF4VxzAhHIMI7yDcSg==", + "version": "15.0.10", + "resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.10.tgz", + "integrity": "sha512-fvDhW4bhmXAeWFNFiInmsGCK83PAqAcQaFyp/3pE/jbdKmFKoRCWr46uZGIfN4msLK/OODhaQ/+US7GSJNDHJg==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -8325,6 +8348,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -9301,6 +9333,18 @@ "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", "license": "MIT" }, + "node_modules/merge-options": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz", + "integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==", + "license": "MIT", + "dependencies": { + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", diff --git a/package.json b/package.json index 3d9b654..ee30d9c 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@expo/vector-icons": "^15.0.3", + "@react-native-async-storage/async-storage": "2.2.0", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", @@ -22,13 +23,14 @@ "expo-font": "~14.0.9", "expo-haptics": "~15.0.7", "expo-image": "~3.0.10", - "expo-linking": "~8.0.8", + "expo-linking": "~8.0.11", "expo-router": "~6.0.13", + "expo-secure-store": "~15.0.8", "expo-splash-screen": "~31.0.10", "expo-status-bar": "~3.0.8", "expo-symbols": "~1.0.7", "expo-system-ui": "~6.0.8", - "expo-web-browser": "~15.0.8", + "expo-web-browser": "~15.0.10", "react": "^19.1.0", "react-dom": "19.1.0", "react-native": "^0.81.5", diff --git a/src/api/apiClient.ts b/src/api/apiClient.ts index 972b984..cbb43be 100644 --- a/src/api/apiClient.ts +++ b/src/api/apiClient.ts @@ -1,8 +1,33 @@ +// oba_frontend/src/api/apiClient.ts import axios from "axios"; +import * as SecureStore from "expo-secure-store"; +import { router } from "expo-router"; +// 본인 PC의 IP 주소로 수정 필수 const BASE_URL = "http://192.168.219.101:9000"; export const apiClient = axios.create({ baseURL: BASE_URL, headers: { "Content-Type": "application/json" }, -}); \ No newline at end of file +}); + +// 요청 인터셉터: 토큰 자동 첨부 +apiClient.interceptors.request.use(async (config) => { + const token = await SecureStore.getItemAsync("accessToken"); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +// 응답 인터셉터: 401 에러 시 로그아웃 처리 +apiClient.interceptors.response.use( + (response) => response, + async (error) => { + if (error.response?.status === 401) { + await SecureStore.deleteItemAsync("accessToken"); + router.replace("/(auth)/login"); + } + return Promise.reject(error); + } +); \ No newline at end of file