From 6c55b726a0df2d7b2552c85c2af7cca55498774b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”>
Date: Mon, 19 Jan 2026 00:41:09 +0900
Subject: [PATCH] =?UTF-8?q?backend=20=EC=97=B0=EB=8F=99=20=EB=B0=8F=20?=
=?UTF-8?q?=EC=98=A4=EB=8B=B5=EB=85=B8=ED=8A=B8,=20=ED=82=A4=EC=9B=8C?=
=?UTF-8?q?=EB=93=9C,=20=EC=9A=94=EC=95=BD,=20=ED=80=B4=EC=A6=88=20?=
=?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app.json | 15 +-
app/(auth)/_layout.tsx | 6 +-
app/(auth)/login.tsx | 87 +-
app/(tabs)/_layout.tsx | 54 +-
app/(tabs)/index.tsx | 407 +++++----
app/(tabs)/my/index.tsx | 315 ++++---
app/(tabs)/report/index.tsx | 1 -
app/(tabs)/wrongArticles/index.tsx | 841 ++++--------------
app/_layout.tsx | 47 +-
app/article/[id].tsx | 209 +++--
app/article/components/ArticleTab.tsx | 420 ++-------
app/article/components/KeywordTab.tsx | 127 ++-
app/article/components/QuizTab.tsx | 715 +++------------
app/article/components/TabBar.tsx | 241 ++---
app/components/PizzaMenu/usePizzaAnimation.ts | 2 +
app/index.tsx | 0
package-lock.json | 62 +-
package.json | 6 +-
src/api/apiClient.ts | 27 +-
19 files changed, 1316 insertions(+), 2266 deletions(-)
create mode 100644 app/index.tsx
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