diff --git a/.env b/.env new file mode 100644 index 0000000..bcb32df --- /dev/null +++ b/.env @@ -0,0 +1,2 @@ +EXPO_PUBLIC_KAKAO_REST_API_KEY=fcc79e9199b5dbcaedfc00bb30b3d4af +EXPO_PUBLIC_SERVER_BASE_URL=https://api.dailysnap.app diff --git a/.eslintignore b/.eslintignore index 290b2fe..f56810c 100644 --- a/.eslintignore +++ b/.eslintignore @@ -2,4 +2,6 @@ node_modules android ios build -dist \ No newline at end of file +dist +tailwind.config.js +app.config.js diff --git a/.eslintrc.js b/.eslintrc.js index 1595451..cec1193 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -19,6 +19,8 @@ module.exports = { "prettier/prettier": "error", "react/react-in-jsx-scope": "off", "@typescript-eslint/consistent-type-imports": "error", + "import/namespace": "off", + "import/no-unresolved": "off", }, parser: "@typescript-eslint/parser", parserOptions: { @@ -27,4 +29,12 @@ module.exports = { ecmaVersion: "latest", sourceType: "module", }, + settings: { + "import/resolver": { + node: { + extensions: [".js", ".jsx", ".ts", ".tsx"], + }, + typescript: false, + }, + }, }; diff --git a/.github/workflows/DAILYSNAP-FE-PR-CI.yml b/.github/workflows/DAILYSNAP-FE-PR-CI.yml index 571e460..7566ca9 100644 --- a/.github/workflows/DAILYSNAP-FE-PR-CI.yml +++ b/.github/workflows/DAILYSNAP-FE-PR-CI.yml @@ -19,7 +19,6 @@ jobs: uses: actions/setup-node@v4 with: node-version: 22.14.0 - cache: "npm" - name: ๐Ÿ“ฆ Install dependencies run: npm install @@ -33,5 +32,6 @@ jobs: - name: โœ… Lint Check run: npm run lint - - name: ๐Ÿ” TypeScript Check - run: npx tsc --noEmit + # TypeScript ๊ฒ€์‚ฌ ๋น„ํ™œ์„ฑํ™”. TODO: ์ถ”ํ›„ ํ™œ์„ฑํ™” + # - name: ๐Ÿ” TypeScript Check + # run: npx tsc --noEmit diff --git a/.gitignore b/.gitignore index 0866253..4fa5e93 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ # dependencies node_modules/ +yarn.lock # Expo .expo/ diff --git a/.prettierignore b/.prettierignore index 290b2fe..c9745b2 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,4 +2,4 @@ node_modules android ios build -dist \ No newline at end of file +dist diff --git a/app.config.js b/app.config.js new file mode 100644 index 0000000..0b762ee --- /dev/null +++ b/app.config.js @@ -0,0 +1,37 @@ +export default { + expo: { + name: "DailySnap-FE", + slug: "DailySnap-FE", + version: "1.0.0", + orientation: "portrait", + scheme: "dailysnap", + userInterfaceStyle: "light", + newArchEnabled: true, + splash: { + resizeMode: "contain", + backgroundColor: "#ffffff" + }, + ios: { + supportsTablet: true, + bundleIdentifier: "com.jhsonny.DailySnapFE" + }, + android: { + adaptiveIcon: { + backgroundColor: "#ffffff" + }, + package: "com.jhsonny.DailySnapFE" + }, + web: { + bundler: "metro", + output: "static", + favicon: "./assets/images/favicon.png" + }, + plugins: [ + "expo-router", + "expo-font" + ], + experiments: { + typedRoutes: true + } + } +}; \ No newline at end of file diff --git a/app.json b/app.json deleted file mode 100644 index b89d0c3..0000000 --- a/app.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "expo": { - "name": "DailySnap-FE", - "slug": "DailySnap-FE", - "version": "1.0.0", - "orientation": "portrait", - "scheme": "myapp", - "userInterfaceStyle": "light", - "newArchEnabled": true, - "splash": { - "resizeMode": "contain", - "backgroundColor": "#ffffff" - }, - "ios": { - "supportsTablet": true, - "bundleIdentifier": "com.jhsonny.DailySnapFE" - }, - "android": { - "adaptiveIcon": { - "backgroundColor": "#ffffff" - }, - "package": "com.jhsonny.DailySnapFE" - }, - "web": { - "bundler": "metro", - "output": "static", - "favicon": "./assets/images/favicon.png" - }, - "plugins": ["expo-router", "expo-font"], - "experiments": { - "typedRoutes": true - } - } -} diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx new file mode 100644 index 0000000..d6ddf4d --- /dev/null +++ b/app/(tabs)/_layout.tsx @@ -0,0 +1,46 @@ +import { Tabs } from "expo-router"; + +export default function TabsLayout() { + return ( + + + + + + + + + + + + ); +} diff --git a/app/(tabs)/archive.tsx b/app/(tabs)/archive.tsx new file mode 100644 index 0000000..9ec979d --- /dev/null +++ b/app/(tabs)/archive.tsx @@ -0,0 +1,6 @@ +import React from "react"; +import ArchivePage from "../../pages/archive/ArchivePage"; + +export default function ArchiveTab() { + return ; +} diff --git a/app/(tabs)/awards.tsx b/app/(tabs)/awards.tsx new file mode 100644 index 0000000..fee2bcd --- /dev/null +++ b/app/(tabs)/awards.tsx @@ -0,0 +1,6 @@ +import React from "react"; +import AwardsPage from "../../pages/awards/AwardsPage"; + +export default function AwardsTab() { + return ; +} diff --git a/app/(tabs)/home.tsx b/app/(tabs)/home.tsx new file mode 100644 index 0000000..278d10e --- /dev/null +++ b/app/(tabs)/home.tsx @@ -0,0 +1,6 @@ +import React from "react"; +import HomePage from "../../pages/home/HomePage"; + +export default function HomeTab() { + return ; +} diff --git a/app/(tabs)/profile.tsx b/app/(tabs)/profile.tsx new file mode 100644 index 0000000..4e2c56c --- /dev/null +++ b/app/(tabs)/profile.tsx @@ -0,0 +1,6 @@ +import React from "react"; +import ProfilePage from "../../pages/profile/ProfilePage"; + +export default function ProfileTab() { + return ; +} diff --git a/app/(tabs)/upload.tsx b/app/(tabs)/upload.tsx new file mode 100644 index 0000000..eb94384 --- /dev/null +++ b/app/(tabs)/upload.tsx @@ -0,0 +1,6 @@ +import React from "react"; +import UploadPage from "../../pages/upload/UploadPage"; + +export default function UploadTab() { + return ; +} diff --git a/app/_layout.tsx b/app/_layout.tsx index 26fd95a..0d15b4a 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,7 +1,11 @@ import NetInfo from "@react-native-community/netinfo"; import { onlineManager, QueryClientProvider } from "@tanstack/react-query"; import { Stack } from "expo-router"; +import { StatusBar } from "expo-status-bar"; +import { SafeAreaProvider } from "react-native-safe-area-context"; import { queryClient } from "../shared/api/query-client"; +import { AuthProvider } from "../features/auth/model/AuthContext"; +import "../global.css"; onlineManager.setEventListener(setOnline => { return NetInfo.addEventListener(state => { @@ -9,12 +13,24 @@ onlineManager.setEventListener(setOnline => { }); }); +export { default as styled } from "nativewind"; + export default function RootLayout() { return ( - - - - - + + + + + + {/* ์ธ์ฆ ๊ด€๋ จ ์Šคํฌ๋ฆฐ */} + + + + {/* ๋ฉ”์ธ ์•ฑ ์Šคํฌ๋ฆฐ */} + + + + + ); } diff --git a/app/index.tsx b/app/index.tsx index 9dbfd26..0e74ba1 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -1,11 +1,31 @@ -import { SafeAreaView, ScrollView, Text } from "react-native"; +/*eslint-disable */ +import React, { useEffect } from "react"; +import { useRouter } from "expo-router"; +import { useAuth } from "../features/auth/model/AuthContext"; +import { View, ActivityIndicator } from "react-native"; export default function App() { + const router = useRouter(); + const { userInfo } = useAuth(); + + useEffect(() => { + const timer = setTimeout(() => { + if (userInfo) { + // ๋กœ๊ทธ์ธ๋œ ์‚ฌ์šฉ์ž๋ฉด, ํ™ˆ ๋„ค๋น„๊ฒŒ์ด์…˜์œผ๋กœ ์ด๋™ + router.replace("/(tabs)/home"); + } else { + // ๋กœ๊ทธ์ธ๋˜์ง€ ์•Š์•˜์œผ๋ฉด ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€๋กœ ์ด๋™ + router.replace("/login"); + } + }, 100); + + return () => clearTimeout(timer); + }, [userInfo, router]); + + // ๋กœ๋”ฉ ํ™”๋ฉด ํ‘œ์‹œ. TODO: ์ถ”ํ›„์— ๋””์ž์ธ ๋œ ๋กœ๋”ฉ์Šคํ”ผ๋„ˆ๋กœ ์ˆ˜์ • return ( - - - App.tsx to start working on your app! - - + + + ); } diff --git a/app/login.tsx b/app/login.tsx new file mode 100644 index 0000000..0e42abd --- /dev/null +++ b/app/login.tsx @@ -0,0 +1,6 @@ +import React from "react"; +import LoginPage from "../pages/auth/LoginPage"; + +export default function Login() { + return ; +} diff --git a/app/login/index.tsx b/app/login/index.tsx deleted file mode 100644 index c8ce499..0000000 --- a/app/login/index.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { View, Text } from "react-native"; - -export default function Login() { - return ( - - Login - - ); -} diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000..c680675 --- /dev/null +++ b/babel.config.js @@ -0,0 +1,19 @@ +module.exports = function (api) { + api.cache(true); + return { + presets: ['babel-preset-expo'], + plugins: [ + 'nativewind/babel', + [ + 'module-resolver', + { + root: ['./'], + extensions: ['.ios.js', '.android.js', '.js', '.ts', '.tsx', '.json'], + alias: { + '@': './', + }, + }, + ], + ], + }; +}; diff --git a/features/auth/hooks/useLogin.ts b/features/auth/hooks/useLogin.ts deleted file mode 100644 index 7c44769..0000000 --- a/features/auth/hooks/useLogin.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function useLogin() { - // @TODO: login -} diff --git a/features/auth/model/AuthContext.tsx b/features/auth/model/AuthContext.tsx new file mode 100644 index 0000000..d2a7b83 --- /dev/null +++ b/features/auth/model/AuthContext.tsx @@ -0,0 +1,58 @@ +import type { ReactNode } from "react"; +import React, { createContext, useContext, useState } from "react"; + +// ์‚ฌ์šฉ์ž ์ •๋ณด ํƒ€์ž… ์ •์˜ +interface UserInfo { + id: number; + email: string; + nickname: string; + profileImage?: string; + accessToken: string; +} + +interface AuthContextType { + userInfo: UserInfo | null; // ํ˜„์žฌ ๋กœ๊ทธ์ธ๋œ ์‚ฌ์šฉ์ž ์ •๋ณด + login: (userInfo: UserInfo) => void; // ๋กœ๊ทธ์ธ ํ•จ์ˆ˜ + logout: () => void; // ๋กœ๊ทธ์•„์›ƒ ํ•จ์ˆ˜ + isLoggedIn: boolean; // ๋กœ๊ทธ์ธ ์ƒํƒœ ํ™•์ธ +} + +// React Context ์ƒ์„ฑ (์ดˆ๊ธฐ๊ฐ’์€ undefined) +const AuthContext = createContext(undefined); + +// ์ธ์ฆ ์ปจํ…์ŠคํŠธ๋ฅผ ์‚ฌ์šฉํ•˜๊ธฐ ์œ„ํ•œ ์ปค์Šคํ…€ ํ›… +export const useAuth = () => { + const context = useContext(AuthContext); + // AuthProvider ์™ธ๋ถ€์—์„œ ์‚ฌ์šฉํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์—๋Ÿฌ ๋ฐœ์ƒ + if (context === undefined) { + throw new Error("useAuth must be used within AuthProvider"); + } + return context; +}; + +interface AuthProviderProps { + children: ReactNode; +} + +// ์ธ์ฆ ์ƒํƒœ๋ฅผ ๊ด€๋ฆฌํ•˜๋Š” Provider ์ปดํฌ๋„ŒํŠธ +export const AuthProvider: React.FC = ({ children }) => { + // ์‚ฌ์šฉ์ž ์ •๋ณด ์ƒํƒœ ๊ด€๋ฆฌ (null์ด๋ฉด ๋กœ๊ทธ์•„์›ƒ ์ƒํƒœ) + const [userInfo, setUserInfo] = useState(null); + + const login = (userInfo: UserInfo) => { + setUserInfo(userInfo); + }; + + const logout = () => { + setUserInfo(null); + }; + + const isLoggedIn = !!userInfo; + + // Context Provider๋กœ ํ•˜์œ„ ์ปดํฌ๋„ŒํŠธ๋“ค์—๊ฒŒ ์ธ์ฆ ์ƒํƒœ์™€ ํ•จ์ˆ˜๋“ค ์ œ๊ณต + return ( + + {children} + + ); +}; diff --git a/features/auth/model/types.ts b/features/auth/model/types.ts new file mode 100644 index 0000000..761da86 --- /dev/null +++ b/features/auth/model/types.ts @@ -0,0 +1,14 @@ +export interface UserInfo { + id: number; + email: string; + nickname: string; + profileImage?: string; + accessToken: string; +} + +export interface SocialLoginProps { + onLoginSuccess: (userInfo: UserInfo) => void; + onLoginError: (error: string) => void; +} + +export type SocialProvider = "kakao" | "naver" | "google"; diff --git a/features/auth/ui/GoogleLoginButton.tsx b/features/auth/ui/GoogleLoginButton.tsx new file mode 100644 index 0000000..b1b0ede --- /dev/null +++ b/features/auth/ui/GoogleLoginButton.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import { TouchableOpacity, Text } from "react-native"; +import type { SocialLoginProps } from "../model/types"; + +export const GoogleLoginButton: React.FC = ({ onLoginSuccess, onLoginError }) => { + return ( + + ๊ตฌ๊ธ€๋กœ ์‹œ์ž‘ํ•˜๊ธฐ + + ); +}; diff --git a/features/auth/ui/KakaoLoginButton.tsx b/features/auth/ui/KakaoLoginButton.tsx new file mode 100644 index 0000000..20297ee --- /dev/null +++ b/features/auth/ui/KakaoLoginButton.tsx @@ -0,0 +1,47 @@ +import React, { useState } from "react"; +import { TouchableOpacity, Text, Modal } from "react-native"; +import { KakaoLoginWebView } from "./KakaoLoginWebView"; +import type { SocialLoginProps } from "../model/types"; + +export const KakaoLoginButton: React.FC = ({ onLoginSuccess, onLoginError }) => { + const [showWebView, setShowWebView] = useState(false); + + const handleKakaoLogin = () => { + setShowWebView(true); + }; + + const handleLoginSuccess = (userInfo: any) => { + console.log("์นด์นด์˜ค ๋กœ๊ทธ์ธ ์„ฑ๊ณต:", userInfo); + setShowWebView(false); + onLoginSuccess(userInfo); + }; + + const handleLoginError = (error: any) => { + console.error("์นด์นด์˜ค ๋กœ๊ทธ์ธ ์‹คํŒจ:", error); + setShowWebView(false); + onLoginError(typeof error === "string" ? error : "์นด์นด์˜ค ๋กœ๊ทธ์ธ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."); + }; + + const handleClose = () => { + setShowWebView(false); + }; + + return ( + <> + + ์นด์นด์˜ค๋กœ ์‹œ์ž‘ํ•˜๊ธฐ + + + + + + + ); +}; diff --git a/features/auth/ui/KakaoLoginWebView.tsx b/features/auth/ui/KakaoLoginWebView.tsx new file mode 100644 index 0000000..8429c87 --- /dev/null +++ b/features/auth/ui/KakaoLoginWebView.tsx @@ -0,0 +1,137 @@ +import React, { useState } from "react"; +import { ActivityIndicator, Alert, Platform, View } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import type { WebViewNavigation } from "react-native-webview"; +import WebView from "react-native-webview"; +import axios from "axios"; + +// kakao developer ํ™˜๊ฒฝ๋ณ€์ˆ˜์—์„œ ์นด์นด์˜ค API ํ‚ค์™€ ์„œ๋ฒ„ URL์„ ๊ฐ€์ ธ์˜ด +const KAKAO_REST_API_KEY = process.env.EXPO_PUBLIC_KAKAO_REST_API_KEY; +const SERVER_BASE_URL = process.env.EXPO_PUBLIC_SERVER_BASE_URL; +const REDIRECT_URI = `${SERVER_BASE_URL}/api/auth/login`; + +interface KakaoLoginWebViewProps { + onLoginSuccess: (userInfo: any) => void; + onLoginError: (error: any) => void; + onClose: () => void; +} + +export function KakaoLoginWebView({ + onLoginSuccess, + onLoginError, + onClose, +}: KakaoLoginWebViewProps) { + const [isLoading, setIsLoading] = useState(false); + const [isChangeNavigate, setIsChangeNavigate] = useState(true); + + // ์นด์นด์˜ค ์ธ์ฆ ์ฝ”๋“œ๋ฅผ ๋ฐ›์•„์„œ ์•ก์„ธ์Šค ํ† ํฐ์œผ๋กœ ๊ตํ™˜ํ•˜๊ณ  ์‚ฌ์šฉ์ž ์ •๋ณด๋ฅผ ๊ฐ€์ ธ์˜ค๋Š” ํ•จ์ˆ˜ + const requestToken = async (code: string) => { + try { + setIsLoading(true); + + // 1๋‹จ๊ณ„: ์ธ์ฆ ์ฝ”๋“œ๋ฅผ ์•ก์„ธ์Šค ํ† ํฐ์œผ๋กœ ๊ตํ™˜ + const tokenResponse = await axios({ + method: "POST", + url: "https://kauth.kakao.com/oauth/token", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + data: new URLSearchParams({ + grant_type: "authorization_code", + client_id: KAKAO_REST_API_KEY!, + redirect_uri: REDIRECT_URI, + code, + }), + }); + + console.log("ํ† ํฐ ์‘๋‹ต:", tokenResponse.data); + const kakaoAccessToken = tokenResponse.data.access_token; + + // 2๋‹จ๊ณ„: ์•ก์„ธ์Šค ํ† ํฐ์œผ๋กœ ์นด์นด์˜ค ์‚ฌ์šฉ์ž ์ •๋ณด ์กฐํšŒ + const userResponse = await axios({ + method: "GET", + headers: { + Authorization: `Bearer ${kakaoAccessToken}`, + }, + url: "https://kapi.kakao.com/v2/user/me", + }); + + console.log("user ์‘๋‹ต:", userResponse.data); + + // ๋ฐ›์•„์˜จ ์‚ฌ์šฉ์ž ์ •๋ณด๋ฅผ ์•ฑ์—์„œ ์‚ฌ์šฉํ•  ํ˜•ํƒœ๋กœ ๋ณ€ํ™˜ + const userInfo = { + id: userResponse.data.id, + email: userResponse.data.kakao_account?.email, + nickname: userResponse.data.kakao_account?.profile?.nickname, + profileImage: userResponse.data.kakao_account?.profile?.profile_image_url, + accessToken: kakaoAccessToken, + }; + + onLoginSuccess(userInfo); + } catch (error) { + console.error("์นด์นด์˜ค ๋กœ๊ทธ์ธ ์—๋Ÿฌ:", error); + onLoginError(error); + Alert.alert("๋กœ๊ทธ์ธ ์‹คํŒจ", "์นด์นด์˜ค ๋กœ๊ทธ์ธ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.", [ + { text: "ํ™•์ธ", onPress: onClose }, + ]); + } finally { + setIsLoading(false); + } + }; + + // ์›น๋ทฐ์˜ URL์ด ๋ณ€๊ฒฝ๋  ๋•Œ๋งˆ๋‹ค ํ˜ธ์ถœ๋˜๋Š” ํ•จ์ˆ˜ + const handleNavigationChangeState = (event: WebViewNavigation) => { + console.log("Navigation state change:", event.url); + + // ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ URL์— ์ธ์ฆ ์ฝ”๋“œ๊ฐ€ ํฌํ•จ๋˜์–ด ์žˆ๋Š”์ง€ ํ™•์ธ + if (event.url.includes(`${REDIRECT_URI}?code=`)) { + const urlParams = new URLSearchParams(event.url.split("?")[1]); + const code = urlParams.get("code"); + + if (code) { + console.log("Authorization code received:", code); + setIsLoading(true); + // ์ธ์ฆ ์ฝ”๋“œ๋ฅผ ์นด์นด์˜ค์—์„œ ๋ฐ›์•„์„œ, ํ† ํฐ ๊ตํ™˜ ํ•จ์ˆ˜ ํ˜ธ์ถœ + requestToken(code); + return; + } + } + + setIsChangeNavigate(event.loading); + }; + + return ( + + {(isLoading || isChangeNavigate) && ( + + + + )} + + + ); +} diff --git a/features/auth/ui/NaverLoginButton.tsx b/features/auth/ui/NaverLoginButton.tsx new file mode 100644 index 0000000..c5029e5 --- /dev/null +++ b/features/auth/ui/NaverLoginButton.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import { TouchableOpacity, Text } from "react-native"; +import type { SocialLoginProps } from "../model/types"; + +export const NaverLoginButton: React.FC = ({ onLoginSuccess, onLoginError }) => { + return ( + + ๋„ค์ด๋ฒ„๋กœ ์‹œ์ž‘ํ•˜๊ธฐ + + ); +}; diff --git a/features/auth/ui/SocialLoginButtons.tsx b/features/auth/ui/SocialLoginButtons.tsx index dbaabce..815c810 100644 --- a/features/auth/ui/SocialLoginButtons.tsx +++ b/features/auth/ui/SocialLoginButtons.tsx @@ -1,9 +1,24 @@ -import { View, Button } from "react-native"; +import React from "react"; +import { View } from "react-native"; +import { KakaoLoginButton } from "./KakaoLoginButton"; +import { NaverLoginButton } from "./NaverLoginButton"; +import { GoogleLoginButton } from "./GoogleLoginButton"; +import type { SocialLoginProps } from "../model/types"; -export function SocialLoginButtons() { +export const SocialLoginButtons: React.FC = ({ + onLoginSuccess, + onLoginError, +}) => { return ( - -