diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index d5dd3b78..00000000 --- a/.eslintrc.js +++ /dev/null @@ -1,57 +0,0 @@ -module.exports = { - extends: ["eslint:recommended"], - plugins: ["@typescript-eslint"], - parser: "@typescript-eslint/parser", - parserOptions: { - ecmaVersion: 12, - sourceType: "module", - ecmaFeatures: { - jsx: true, - }, - }, - env: { - es6: true, - browser: true, - node: true, - }, - rules: { - indent: "off", - "brace-style": "off", - "arrow-parens": "off", - "no-console": "off", - "no-undef": "off", - "max-len": "off", - "sort-imports": "off", - "no-restricted-exports": "off", - "no-unused-vars": "off", - "object-curly-newline": "off", - "max-params": ["error", 3], - "jsx-quotes": "off", - "no-confusing-arrow": "off", - "no-nested-ternary": "off", - "comma-spacing": "off", - "function-paren-newline": "off", - "implicit-arrow-linebreak": "off", - "operator-linebreak": "off", - "no-underscore-dangle": "off", - "no-useless-constructor": "off", - "no-use-before-define": "off", - "no-param-reassign": "off", - "no-return-await": "off", - "prefer-regex-literals": "off", - "lines-between-class-members": "off", - "import/no-unresolved": "off", - // typescript - "@typescript-eslint/no-explicit-any": "error", - "@typescript-eslint/no-unused-vars": "error", - "@typescript-eslint/restrict-template-expressions": "off", - "@typescript-eslint/no-var-requires": "error", - "@typescript-eslint/no-unsafe-call": "off", - "@typescript-eslint/no-unsafe-member-access": "off", - "@typescript-eslint/no-unsafe-assignment": "off", - "@typescript-eslint/no-unsafe-argument": "off", - "@typescript-eslint/no-unsafe-return": "off", - "@typescript-eslint/ban-types": "off", - "@typescript-eslint/ban-ts-comment": "off", - }, -}; diff --git a/.github/workflows/review-assign-action.yml b/.github/workflows/review-assign-action.yml new file mode 100644 index 00000000..21608d55 --- /dev/null +++ b/.github/workflows/review-assign-action.yml @@ -0,0 +1,50 @@ +name: Review Assign + +on: + pull_request: + types: [opened, ready_for_review] + +jobs: + assign: + permissions: + actions: write + checks: write + contents: write + deployments: write + discussions: write + issues: write + id-token: read + packages: write + pages: write + pull-requests: write + repository-projects: write + security-events: write + statuses: write + runs-on: ubuntu-latest + steps: + - if: github.base_ref == 'main' # base branch name is 'master' + run: echo REVIEWERS=inseong-so >> $GITHUB_ENV + - if: startsWith(github.base_ref, 'team1') + run: echo REVIEWERS=headring, KimHunJin, hyjoong her0707 >> $GITHUB_ENV + - if: startsWith(github.base_ref, 'team2') + run: echo REVIEWERS=Bsfla, SeolJaeHyeok, choisy9619, kyung-jun >> $GITHUB_ENV + - if: startsWith(github.base_ref, 'team3') + run: echo REVIEWERS=sgsg9447, kingyong9169, 2dowon, jqkk >> $GITHUB_ENV + - if: startsWith(github.base_ref, 'team4') + run: echo REVIEWERS=kimseongchan-kr, cham0287, hyeon9782 >> $GITHUB_ENV + - if: startsWith(github.base_ref, 'team5') + run: echo REVIEWERS=2-NOW, hyew-kim, geeonie >> $GITHUB_ENV + - if: startsWith(github.base_ref, 'team6') + run: echo REVIEWERS=areumsheep, ludacirs, innocarpe >> $GITHUB_ENV + - if: startsWith(github.base_ref, 'team7') + run: echo REVIEWERS=endmoseung, steven-yn, ding-co, mandarin-sep >> $GITHUB_ENV + - if: startsWith(github.base_ref, 'team8') + run: echo REVIEWERS=HOJOON07, jiji-hoon96, 71summernight, seung-wan >> $GITHUB_ENV + - if: startsWith(github.base_ref, 'team9') + run: echo REVIEWERS=Siihyun, hhhminme, 0uizi0, brgndyy >> $GITHUB_ENV + - if: startsWith(github.base_ref, 'team10') + run: echo REVIEWERS=Leejha, steadily-worked >> $GITHUB_ENV + - uses: hkusu/review-assign-action@v1 + with: + assignees: ${{ github.actor }} + reviewers: ${{ env.REVIEWERS }} diff --git a/.prettierrc.js b/.prettierrc.js deleted file mode 100644 index 57294e79..00000000 --- a/.prettierrc.js +++ /dev/null @@ -1,16 +0,0 @@ -module.exports = { - plugins: [require("@trivago/prettier-plugin-sort-imports")], - singleQuote: true, - semi: true, - useTabs: false, - tabWidth: 2, - trailingComma: "all", - printWidth: 100, - bracketSpacing: true, - arrowParens: "always", - endOfLine: "auto", - importOrder: ["^@/*", "^./(.*)", "^types", "^public"], - importOrderSortSpecifiers: true, - importOrderGroupNamespaceSpecifiers: true, - importOrderCaseInsensitive: true, -}; diff --git a/README.md b/README.md index 4f1c8604..4cd3a771 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,77 @@ -# ![RealWorld Example App](./assets/logo.png) +# Next World -> ### [YOUR_FRAMEWORK] codebase containing real world examples (CRUD, auth, advanced patterns, etc) that adheres to the [RealWorld](https://github.com/gothinkster/realworld) spec and API. +### Real World에서 제공해주는 API를 활용하여 블로그를 개발한 프로젝트입니다. +## 프로젝트 목표 -### [Demo](https://demo.realworld.io/)    [RealWorld](https://github.com/gothinkster/realworld) +### Next.js 13 App Router의 사용법을 익히고 SSR 이해하기 +### Vanilla Extract의 사용법을 익히고 제로 런타임 이해하기 -This codebase was created to demonstrate a fully fledged fullstack application built with **[YOUR_FRAMEWORK]** including CRUD operations, authentication, routing, pagination, and more. +### React Query의 사용법을 익히고 효율적인 데이터 패칭을 구현하기 -We've gone to great lengths to adhere to the **[YOUR_FRAMEWORK]** community styleguides & best practices. +### Zustand의 사용법을 익히고 Flux 패턴 이해하기 -For more information on how to this works with other frontends/backends, head over to the [RealWorld](https://github.com/gothinkster/realworld) repo. +## Stacks +### Environment -# How it works +
+ + + +
-> Describe the general architecture of your app here +### Config -# Getting started + -> npm install, npm start, etc. +### Development + +
+ + + + + +
+ +## 페이지 구성 + +### 메인 페이지 (Article 목록) + +### Article 상세 페이지 + +### 로그인 페이지 + +### 회원가입 페이지 + +### 설정 페이지 + +### 글쓰기 페이지 + +### 프로필 페이지 + +## 주요 기능 + +- Article CRUD 기능 구현 (전체, 태그, 좋아요, 팔로우) +- Comment CRD 기능 구현 +- User & Auth 기능 구현 (로그인, 회원가입, 정보 수정) +- 좋아요 & 팔로우 기능 구현 + +## Future Works + +- [ ] cookies 넣는 부분 util 함수로 빼기 +- [ ] route handler Response 일관성 있게 통일하기 +- [ ] Error Message에 따라 알맞은 에러 처리 +- [ ] 사용하지 않는 함수들 제거하기 +- [ ] 좋아요 & 팔로우 버튼 + - [ ] Optimistic Updates를 활용한 사용자 경험 향상 + - [ ] 일관된 UI를 위해 button 크기 고정 (좋아요 수가 99개가 넘어갈 경우 99+로 표시) +- [ ] ArticlePreview + - [ ] 제목 크기 고정 및 크기를 넘어가면 ... 처리 + - [ ] 한 번 봤던 게시글 표시하기 (체크 표시 또는 배경색을 다르게) +- [ ] alert을 사용하지 않고 Dialog 컴포넌트 구현 +- [ ] 페이지 별 스켈레톤 UI 적용 +- [ ] Vanilla Extract 기능을 활용하여 CSS 정리 (급하게 하느라 너무 막 짠 거 같습니다..) +- [ ] 테스트 코드 추가 diff --git a/api/http/httpClient.ts b/api/http/httpClient.ts new file mode 100644 index 00000000..b00def70 --- /dev/null +++ b/api/http/httpClient.ts @@ -0,0 +1,43 @@ +import { HTTP_METHOD, COMMON_HEADERS } from '@/constants/api'; +import { API_BASE_URL } from '@/constants/env'; + +class HttpClient { + BASE_URL = API_BASE_URL; + + constructor() {} + + async request(url: string, options: any, method: string) { + const response = await fetch(`${this.BASE_URL}${url}`, { + method, + headers: { + ...COMMON_HEADERS, + ...options.headers, + }, + ...options, + }); + + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + + return response; + } + + get(url: string, options = {}) { + return this.request(url, options, HTTP_METHOD.GET); + } + + post(url: string, options = {}) { + return this.request(url, options, HTTP_METHOD.POST); + } + + put(url: string, options = {}) { + return this.request(url, options, HTTP_METHOD.PUT); + } + + delete(url: string, options = {}) { + return this.request(url, options, HTTP_METHOD.DELETE); + } +} + +export const httpClient = new HttpClient(); diff --git a/app/[slug]/page.tsx b/app/[slug]/page.tsx deleted file mode 100644 index 3ef8d5e0..00000000 --- a/app/[slug]/page.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import ArticleTab from '@/components/article/ArticleTab'; -import { SettingIcon } from '@/composables/icons'; -import { container } from '@/styles/common.css'; -import { settingButton, userBlock, userImage, userInfo, userName } from '@/styles/profile.css'; -import Image from 'next/image'; -import Link from 'next/link'; - -const ProfilePage = async () => { - // const user = await fetchUser(); - return ( -
-
-
- Profile -
hyeon9782
- - -   Edit Profile Settings - -
-
-
- -
-
- ); -}; - -export default ProfilePage; diff --git a/app/[slug]/loading.tsx b/app/[username]/loading.tsx similarity index 100% rename from app/[slug]/loading.tsx rename to app/[username]/loading.tsx diff --git a/app/[username]/page.tsx b/app/[username]/page.tsx new file mode 100644 index 00000000..9a1c16fc --- /dev/null +++ b/app/[username]/page.tsx @@ -0,0 +1,30 @@ +'use client'; + +import ArticleList from '@/components/article/ArticleList'; +import ProfileBox from '@/components/profile/ProfileBox'; +import useProfile from '@/hooks/useProfile'; +import { container } from '@/styles/common.css'; +import dynamic from 'next/dynamic'; +import { Suspense } from 'react'; + +const ArticleTab = dynamic(() => import('@/components/article/ArticleTab'), { ssr: false }); +type Props = { + params: { username: string }; +}; +const ProfilePage = ({ params: { username } }: Props) => { + const { profile } = useProfile({ username }); + + return ( +
+ +
+ + 리스트 로딩 중...
}> + + + +
+ ); +}; + +export default ProfilePage; diff --git a/app/api/articles/[slug]/route.ts b/app/api/articles/[slug]/route.ts new file mode 100644 index 00000000..e6cd446e --- /dev/null +++ b/app/api/articles/[slug]/route.ts @@ -0,0 +1,60 @@ +import { http } from '@/utils/http'; +import { NextRequest, NextResponse } from 'next/server'; + +async function GET(req: NextRequest, route: { params: { slug: string } }) { + try { + const slug = route.params.slug; + const token = req.cookies.get('token')?.value || ''; + + const res = await http.get(`/articles/${slug}`, { + headers: { + 'Content-Type': 'application/json; charset=utf-8', + Authorization: `Token ${token}`, + }, + }); + + return NextResponse.json({ message: 'Article Get Success', success: true, data: res }); + } catch (error: any) { + console.log(error); + return NextResponse.json({ error: error.message }, { status: 400 }); + } +} + +async function PUT(req: NextRequest, route: { params: { slug: string } }) { + try { + const body = await req.json(); + const slug = route.params.slug; + const token = req.cookies.get('token')?.value || ''; + + const res = await http.put(`/articles/${slug}`, body, { + headers: { + 'Content-Type': 'application/json; charset=utf-8', + Authorization: `Token ${token}`, + }, + }); + + return NextResponse.json({ message: 'Article Update Success', success: true, data: res }); + } catch (error: any) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } +} + +async function DELETE(req: NextRequest, route: { params: { slug: string } }) { + try { + const slug = route.params.slug; + const token = req.cookies.get('token')?.value || ''; + + const res = await http.delete(`/articles/${slug}`, { + headers: { + 'Content-Type': 'application/json; charset=utf-8', + Authorization: `Token ${token}`, + }, + }); + + return NextResponse.json({ message: 'Article Delete Success', success: true, data: res }); + } catch (error: any) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } +} + +export { GET, PUT, DELETE }; diff --git a/app/api/articles/favorite/[slug]/route.ts b/app/api/articles/favorite/[slug]/route.ts new file mode 100644 index 00000000..337dda24 --- /dev/null +++ b/app/api/articles/favorite/[slug]/route.ts @@ -0,0 +1,41 @@ +import { http } from '@/utils/http'; +import { NextRequest, NextResponse } from 'next/server'; + +// 좋아요 +async function POST(request: NextRequest, route: { params: { slug: string } }) { + try { + const slug = route.params.slug; + const token = request.cookies.get('token')?.value || ''; + + const res = await http.post(`/articles/${slug}/favorite`, '', { + headers: { + 'Content-Type': 'application/json; charset=utf-8', + Authorization: `Token ${token}`, + }, + }); + + return NextResponse.json({ message: 'Favorite Success', success: true, data: res }); + } catch (error: any) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } +} + +// 좋아요 취소 +async function DELETE(request: NextRequest, route: { params: { slug: string } }) { + try { + const slug = route.params.slug; + const token = request.cookies.get('token')?.value || ''; + const res = await http.delete(`/articles/${slug}/favorite`, { + headers: { + 'Content-Type': 'application/json; charset=utf-8', + Authorization: `Token ${token}`, + }, + }); + + return NextResponse.json({ message: 'Un Favorite Success', success: true, data: res }); + } catch (error: any) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } +} + +export { POST, DELETE }; diff --git a/app/api/articles/feed/route.ts b/app/api/articles/feed/route.ts new file mode 100644 index 00000000..db3baf6d --- /dev/null +++ b/app/api/articles/feed/route.ts @@ -0,0 +1,14 @@ +import { getArticlesFeed } from '@/services/articles'; +import { NextRequest, NextResponse } from 'next/server'; + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + + const page = searchParams.get('page'); + + const token = request.cookies.get('token')?.value || ''; + + const { articles, articlesCount } = await getArticlesFeed(Number(page), token); + + return NextResponse.json({ articles, articlesCount }); +} diff --git a/app/api/articles/my/route.ts b/app/api/articles/my/route.ts new file mode 100644 index 00000000..be7aff7e --- /dev/null +++ b/app/api/articles/my/route.ts @@ -0,0 +1,14 @@ +import { getArticlesWithAuthorAPI } from '@/services/articles'; +import { NextRequest, NextResponse } from 'next/server'; + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + + const username = searchParams.get('username') || ''; + + const token = request.cookies.get('token')?.value || ''; + + const { articles, articlesCount } = await getArticlesWithAuthorAPI(username, token); + + return NextResponse.json({ articles, articlesCount }); +} diff --git a/app/api/articles/new/route.ts b/app/api/articles/new/route.ts new file mode 100644 index 00000000..e28ce3a4 --- /dev/null +++ b/app/api/articles/new/route.ts @@ -0,0 +1,20 @@ +import { http } from '@/utils/http'; +import { NextRequest, NextResponse } from 'next/server'; + +export async function POST(request: NextRequest) { + try { + const token = request.cookies.get('token')?.value || ''; + const body = await request.json(); + + const res = await http.post('/articles', body, { + headers: { + 'Content-Type': 'application/json; charset=utf-8', + Authorization: `Token ${token}`, + }, + }); + + return NextResponse.json({ message: 'Create Article Success', success: true, data: res }); + } catch (error: any) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } +} diff --git a/app/api/articles/route.ts b/app/api/articles/route.ts index f19aa320..78fe09a2 100644 --- a/app/api/articles/route.ts +++ b/app/api/articles/route.ts @@ -1,15 +1,19 @@ -import { fetchArticlesWithTag, registerArticle } from '@/services/articles'; +import { getArticlesAPI, getArticlesWithFavoritedAPI } from '@/services/articles'; import { NextRequest, NextResponse } from 'next/server'; -export async function GET(req: NextRequest) { - const tag = req.nextUrl.searchParams.get('tag') ?? ''; - const res = await fetchArticlesWithTag(tag); - return NextResponse.json(res); -} +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + + const page = searchParams.get('page'); + const username = searchParams.get('username'); -export async function POST(req: NextRequest) { - console.log(req.body); + const token = request.cookies.get('token')?.value || ''; - const res = await registerArticle(req.body); - return NextResponse.json(res); + if (username) { + const { articles, articlesCount } = await getArticlesWithFavoritedAPI(username, token, Number(page)); + return NextResponse.json({ articles, articlesCount }); + } else { + const { articles, articlesCount } = await getArticlesAPI(token, Number(page)); + return NextResponse.json({ articles, articlesCount }); + } } diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts new file mode 100644 index 00000000..3cf4120d --- /dev/null +++ b/app/api/auth/login/route.ts @@ -0,0 +1,25 @@ +import { loginAPI } from '@/services/users'; +import { NextRequest, NextResponse } from 'next/server'; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + + const res = await loginAPI(body); + + const response = NextResponse.json({ + message: 'Login successfull', + success: true, + user: res.user, + }); + + response.cookies.set('token', res.user.token, { + httpOnly: true, + path: '/', + }); + + return response; + } catch (error: any) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 00000000..d569b320 --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -0,0 +1,11 @@ +import { NextResponse } from 'next/server'; + +export async function GET() { + try { + const response = NextResponse.json({ message: 'Logout successful', success: true }); + response.cookies.set('token', '', { httpOnly: true, expires: new Date(0) }); + return response; + } catch (error: any) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/app/api/auth/route.ts b/app/api/auth/route.ts deleted file mode 100644 index 517a5b5b..00000000 --- a/app/api/auth/route.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { http } from '@/libs/http'; - -async function POST(req: NextRequest) { - const body = await req.json(); - - const res = await http.post(`${process.env.API_URL}/users/login`, body); - - console.log(res); - - const response = NextResponse.redirect('http://localhost:3000/', { status: 302 }); - response.cookies.set('auth', res?.user?.token, { - httpOnly: true, - secure: true, - }); - response.json(res); - return response; -} - -export { POST }; diff --git a/app/api/auth/signup/route.ts b/app/api/auth/signup/route.ts new file mode 100644 index 00000000..b51a384e --- /dev/null +++ b/app/api/auth/signup/route.ts @@ -0,0 +1,23 @@ +import { registerUserAPI } from '@/services/users'; +import { NextRequest, NextResponse } from 'next/server'; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const res = await registerUserAPI(body); + + const response = NextResponse.json({ + message: 'Login successfull', + success: true, + user: res.user, + }); + + response.cookies.set('token', res.user.token, { + httpOnly: true, + path: '/', + }); + return response; + } catch (error: any) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/app/api/auth/user/route.ts b/app/api/auth/user/route.ts new file mode 100644 index 00000000..88d3fe4b --- /dev/null +++ b/app/api/auth/user/route.ts @@ -0,0 +1,25 @@ +import { getUserAPI, updateUserAPI } from '@/services/users'; +import { NextRequest, NextResponse } from 'next/server'; + +export async function GET(request: NextRequest) { + try { + const token = request.cookies.get('token')?.value || ''; + const res = await getUserAPI(token); + return NextResponse.json({ message: 'Success', success: true, data: res }); + } catch (error: any) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } +} + +export async function PUT(request: NextRequest) { + try { + const body = await request.json(); + const token = request.cookies.get('token')?.value || ''; + const res = await updateUserAPI(body, token); + console.log(res); + + return NextResponse.json({ message: 'Success', success: true, data: res }); + } catch (error: any) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } +} diff --git a/app/api/comments/[slug]/route.ts b/app/api/comments/[slug]/route.ts new file mode 100644 index 00000000..150ef653 --- /dev/null +++ b/app/api/comments/[slug]/route.ts @@ -0,0 +1,63 @@ +import { http } from '@/utils/http'; +import { NextRequest, NextResponse } from 'next/server'; + +async function GET(req: NextRequest, route: { params: { slug: string } }) { + try { + const slug = route.params.slug; + const token = req.cookies.get('token')?.value || ''; + + const res = await http.get(`/articles/${slug}/comments`, { + headers: { + 'Content-Type': 'application/json; charset=utf-8', + Authorization: `Token ${token}`, + }, + }); + + return NextResponse.json({ message: 'Comment Get Success', success: true, data: res }); + } catch (error: any) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } +} + +async function POST(req: NextRequest, route: { params: { slug: string } }) { + try { + const body = await req.json(); + const slug = route.params.slug; + const token = req.cookies.get('token')?.value || ''; + + const res = await http.post(`/articles/${slug}/comments`, body, { + headers: { + 'Content-Type': 'application/json; charset=utf-8', + Authorization: `Token ${token}`, + }, + }); + + return NextResponse.json({ message: 'Comment Create Success', success: true, data: res }); + } catch (error: any) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } +} + +async function DELETE(req: NextRequest, route: { params: { slug: string } }) { + try { + const { searchParams } = new URL(req.url); + + const id = searchParams.get('id'); + const slug = route.params.slug; + + const token = req.cookies.get('token')?.value || ''; + + const res = await http.delete(`/articles/${slug}/comments/${id}`, { + headers: { + 'Content-Type': 'application/json; charset=utf-8', + Authorization: `Token ${token}`, + }, + }); + + return NextResponse.json({ message: 'Comment Delete Success', success: true, data: res }); + } catch (error: any) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } +} + +export { GET, POST, DELETE }; diff --git a/app/api/profiles/[username]/route.ts b/app/api/profiles/[username]/route.ts new file mode 100644 index 00000000..96008131 --- /dev/null +++ b/app/api/profiles/[username]/route.ts @@ -0,0 +1,63 @@ +import { http } from '@/utils/http'; +import { NextRequest, NextResponse } from 'next/server'; + +async function GET(req: NextRequest, route: { params: { username: string } }) { + const username = route.params.username; + console.log('route User Name' + username); + console.log(username.replace('@', '')); + + const token = req.cookies.get('token')?.value || ''; + + try { + const response = await http.get(`/profiles/${username.replace('@', '')}`, { + headers: { + Authorization: `Token ${token}`, + }, + }); + console.log('route'); + + console.log(response); + + return NextResponse.json({ message: 'Get a Profile Success', response }); + } catch (err) { + return NextResponse.json({ message: 'Get a Profile Fail', err }); + } +} + +// Follow a user +async function POST(req: NextRequest, route: { params: { username: string } }) { + const username = route.params.username; + + const token = req.cookies.get('token')?.value || ''; + + try { + const response = await http.post(`/profiles/${username}/follow`, '', { + headers: { + Authorization: `Token ${token}`, + }, + }); + + return NextResponse.json({ message: 'Follow Success', response }); + } catch (err) { + return NextResponse.json({ message: 'Follow Fail', err }); + } +} + +// Unfollow a user +async function DELETE(req: NextRequest, route: { params: { username: string } }) { + const username = route.params.username; + const token = req.cookies.get('token')?.value || ''; + try { + const response = await http.delete(`/profiles/${username}/follow`, { + headers: { + Authorization: `Token ${token}`, + }, + }); + + return NextResponse.json({ message: 'Unfollow Success', response }); + } catch (err) { + return NextResponse.json({ message: 'Unfollow Fail', err }); + } +} + +export { GET, POST, DELETE }; diff --git a/app/api/profiles/route.ts b/app/api/profiles/route.ts deleted file mode 100644 index 38300314..00000000 --- a/app/api/profiles/route.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { NextRequest } from 'next/server'; - -function GET(req: NextRequest) { - const username = req.nextUrl.searchParams; - const url = req.nextUrl; - console.log(username); - - console.log(url); -} - -export { GET }; diff --git a/app/api/user/route.ts b/app/api/user/route.ts new file mode 100644 index 00000000..c5525f61 --- /dev/null +++ b/app/api/user/route.ts @@ -0,0 +1,21 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getUserAPI, updateUserAPI } from '@/services/users'; + +export async function GET(req: NextRequest) { + const token = req.cookies.get('token')?.value || ''; + + const { user } = await getUserAPI(token); + + return NextResponse.json({ + message: 'Login successfull', + success: true, + user, + }); +} + +export async function PUT(req: NextRequest) { + const token = req.cookies.get('token')?.value || ''; + const user = await req.json(); + + return updateUserAPI(user, token); +} diff --git a/app/api/users/route.ts b/app/api/users/route.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/app/article/[slug]/page.tsx b/app/article/[slug]/page.tsx index 327832fa..fe854600 100644 --- a/app/article/[slug]/page.tsx +++ b/app/article/[slug]/page.tsx @@ -1,55 +1,76 @@ -import CommentCard from '@/components/comments/CommentCard'; -import CommentForm from '@/components/comments/CommentForm'; +'use client'; +import ArticleDeleteButton from '@/components/article/ArticleDeleteButton'; +import ArticleEditButton from '@/components/article/ArticleEditButton'; +import CommentBox from '@/components/comments/CommentBox'; import Banner from '@/components/layouts/Banner'; import TagList from '@/components/tags/TagList'; import FavoriteButton from '@/components/user/FavoriteButton'; import FollowButton from '@/components/user/FollowButton'; import UserBox from '@/components/user/UserBox'; -import { fetchArticle } from '@/services/articles'; +import useUserStore from '@/stores/useUserStore'; import { articleContent, articleDetailTitle } from '@/styles/article.css'; -import { container, flex, flexCenter, flexRow, justifyCenter, paddingTB, textCenter } from '@/styles/common.css'; -import { Article } from '@/types'; -import Link from 'next/link'; -import React from 'react'; +import { container, flex, justifyCenter, paddingTB } from '@/styles/common.css'; +import { User } from '@/types'; +import { useQuery } from '@tanstack/react-query'; +import React, { Suspense } from 'react'; type Props = { params: { slug: string }; }; -const ArticlePage = async ({ params: { slug } }: Props) => { - const { title, author, createdAt, body, tagList, favoritesCount } = await fetchArticle
(slug); - const user = true; +const ArticlePage = ({ params: { slug } }: Props) => { + const { username } = useUserStore() as User; + + const { data: article } = useQuery({ + queryKey: ['article', slug], + queryFn: async () => await fetch(`/api/articles/${slug}`).then(res => res.json()), + select: res => res.data.article, + }); + + const { title, author, createdAt, body, tagList, favoritesCount } = article; + return (
- -

{title}

-
- - - -
-
-
-

{body}

- -
-
- -   - -
-
- {user ? ( -
- - -
- ) : ( -
- Sign in or sign up to add comments on this - article. -
- )} + 로딩중...
}> + +

{title}

+
+ + + {author.username === username ? ( +
+ + +
+ ) : ( +
+ + +
+ )} +
+
+ + 로딩중...
}> +
+

{body}

+ +
+
+ + {author.username === username ? ( +
+ + +
+ ) : ( +
+ + +
+ )} +
- + +
); }; diff --git a/app/editor/[slug]/page.tsx b/app/editor/[slug]/page.tsx index 5d90da0e..79daed01 100644 --- a/app/editor/[slug]/page.tsx +++ b/app/editor/[slug]/page.tsx @@ -1,7 +1,14 @@ -import React from 'react'; - -const EditorUpdatePage = () => { - return
EditorUpdatePage
; +'use client'; +import EditForm from '@/components/editor/EditForm'; +type Props = { + params: { slug: string }; +}; +const EditorUpdatePage = ({ params: { slug } }: Props) => { + return ( +
+ +
+ ); }; export default EditorUpdatePage; diff --git a/app/editor/page.tsx b/app/editor/page.tsx index cf5adbba..8b7af91a 100644 --- a/app/editor/page.tsx +++ b/app/editor/page.tsx @@ -1,66 +1,10 @@ -'use client'; -import TagInput from '@/components/editor/TagInput'; -import { registerArticle } from '@/services/articles'; -import { articleTextarea } from '@/styles/article.css'; -import { commentTextarea } from '@/styles/comments.css'; -import { container, input } from '@/styles/common.css'; -import { editorForm, editorButton } from '@/styles/editor.css'; -import React, { useState } from 'react'; +import EditForm from '@/components/editor/EditForm'; +import { container } from '@/styles/common.css'; const EditorPage = () => { - const [formData, setFormData] = useState({ - title: '', - description: '', - body: '', - tagList: [], - }); - - const handleSubmit = async (e: any) => { - e.preventDefault(); - // console.log('들어옴'); - - // const res = await registerArticle(); - // console.log(res); - }; - - const handleChange = (e: any) => { - setFormData(prev => ({ - ...prev, - [e.target.name]: e.target.value, - })); - }; return (
-
- - - - -
- -
- +
); }; diff --git a/assets/favicon.ico b/app/favicon.ico similarity index 100% rename from assets/favicon.ico rename to app/favicon.ico diff --git a/app/layout.tsx b/app/layout.tsx index ed88ad0f..fde1883f 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -8,6 +8,9 @@ import Providers from '@/libs/Providers'; export const metadata: Metadata = { title: 'next world', description: 'next world by hyeon', + icons: { + icon: '/favicon.ico', + }, }; export default function RootLayout({ children }: { children: ReactNode }) { diff --git a/app/loading.tsx b/app/loading.tsx index a359df7d..20ffa831 100644 --- a/app/loading.tsx +++ b/app/loading.tsx @@ -1,5 +1,3 @@ -import React from 'react'; - const HomeLoading = () => { return
HomeLoading...
; }; diff --git a/app/login/page.tsx b/app/login/page.tsx index 718cda6c..54661b13 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -1,82 +1,12 @@ -'use client'; - -import { loginAPI } from '@/services/users'; -import useUserStore from '@/stores/useUserStore'; -import { form, question, title } from '@/styles/account.css'; -import { input, container, flexCenter, flexRow, fillGreenButton } from '@/styles/common.css'; -import { buttonBox } from '@/styles/layout.css'; -import { LoginUser } from '@/types'; -import { useMutation } from '@tanstack/react-query'; -import { useRouter } from 'next/navigation'; -import { ChangeEvent, FormEvent, useState } from 'react'; +import FormHead from '@/components/account/FormHead'; +import SignInForm from '@/components/account/SignInForm'; +import { container, flexCenter, flexRow } from '@/styles/common.css'; const LoginPage = () => { - const router = useRouter(); - const { login } = useUserStore(); - - const [formData, setFormData] = useState({ - email: '', - password: '', - }); - - const { mutate, isLoading } = useMutation({ - mutationFn: loginAPI, - onError: error => { - setFormData({ - email: '', - password: '', - }); - alert('아이디 또는 비밀번호가 잘못되었습니다.'); - }, - onSuccess: res => { - login({ - ...res.user, - }); - router.push('/'); - }, - }); - - const handleSubmit = (e: FormEvent) => { - e.preventDefault(); - mutate({ - ...formData, - }); - }; - - const handleChange = (e: ChangeEvent) => { - const { name, value } = e.target; - setFormData(prev => ({ - ...prev, - [name]: value, - })); - }; return (
-
Sign in
-
Need an account?
-
- - -
- -
-
+ +
); }; diff --git a/app/page.tsx b/app/page.tsx index a792e799..06480169 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,18 +1,23 @@ -import ArticleList from '@/components/article/ArticleList'; -import ArticleTab from '@/components/article/ArticleTab'; -import Banner from '@/components/layouts/Banner'; import SideBar from '@/components/layouts/SideBar'; + import { articleContainer } from '@/styles/article.css'; -import { container, flex } from '@/styles/common.css'; +import { container, flex, textCenter } from '@/styles/common.css'; import { bannerDescription, bannerTitle } from '@/styles/home.css'; +import dynamic from 'next/dynamic'; import { Suspense } from 'react'; +const ArticleTab = dynamic(() => import('@/components/article/ArticleTab'), { ssr: false }); +const ArticleList = dynamic(() => import('@/components/article/ArticleList'), { ssr: false }); +const Banner = dynamic(() => import('@/components/layouts/Banner'), { ssr: false }); + export default function Page() { return (
-

conduit

-

A place to share your knowledge.

+
+

conduit

+

A place to share your knowledge.

+
diff --git a/app/register/page.tsx b/app/register/page.tsx index 8ab918a6..9bb7eb6f 100644 --- a/app/register/page.tsx +++ b/app/register/page.tsx @@ -1,91 +1,12 @@ -'use client'; -import { registerUserAPI } from '@/services/users'; -import useUserStore from '@/stores/useUserStore'; -import { form, question, title } from '@/styles/account.css'; -import { input, container, flexRow, flexCenter, fillGreenButton } from '@/styles/common.css'; -import { buttonBox } from '@/styles/layout.css'; -import { NewUser } from '@/types'; -import { useMutation } from '@tanstack/react-query'; -import { useRouter } from 'next/navigation'; - -import { ChangeEvent, FormEvent, useState } from 'react'; +import FormHead from '@/components/account/FormHead'; +import SignUpForm from '@/components/account/SignUpForm'; +import { container, flexRow, flexCenter } from '@/styles/common.css'; const RegisterPage = () => { - const router = useRouter(); - const { login } = useUserStore(); - const [formData, setFormData] = useState({ - username: '', - email: '', - password: '', - }); - - const { mutate, isLoading } = useMutation({ - mutationFn: registerUserAPI, - onError: error => { - alert('회원가입에 실패했습니다.'); - console.log(error); - }, - onSuccess: res => { - alert('회원가입에 성공했습니다.'); - login({ - ...res.user, - }); - router.push('/'); - }, - }); - - const handleSubmit = (e: FormEvent) => { - e.preventDefault(); - - mutate({ - ...formData, - }); - }; - - const handleChange = (e: ChangeEvent) => { - const { name, value } = e.target; - setFormData(prev => ({ - ...prev, - [name]: value, - })); - }; - return (
-
Sign up
-
Have an account?
-
- - - -
- -
-
+ +
); }; diff --git a/app/settings/page.tsx b/app/settings/page.tsx index ed259661..a1cbed6b 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -1,110 +1,17 @@ -'use client'; -import { updateUserAPI } from '@/services/users'; -import useUserStore from '@/stores/useUserStore'; -import { articleTextarea } from '@/styles/article.css'; -import { container, flex, hr, input } from '@/styles/common.css'; -import { logoutButton, settingBlock, settingForm, settingTitle, updateButton } from '@/styles/settings.css'; -import { useMutation } from '@tanstack/react-query'; -import { useState } from 'react'; +import LogoutButton from '@/components/settings/LogoutButton'; +import SettingForm from '@/components/settings/SettingForm'; -const SettingsPage = () => { - const { email, username, image, bio, password, logout, updateUser } = useUserStore(); - - // 초기화 함수로 전환 - const [formData, setFormData] = useState({ - image, - username, - bio, - email, - password, - }); - const { mutate, isLoading } = useMutation({ - mutationFn: updateUserAPI, - onError: err => { - console.error(err); - }, - onSuccess: res => { - alert('회원 정보를 변경했습니다!'); - updateUser({ - ...res.user, - }); - }, - }); - - const handleSubmit = async (e: any) => { - e.preventDefault(); +import { container, hr } from '@/styles/common.css'; +import { settingBlock, settingTitle } from '@/styles/settings.css'; - mutate({ - ...formData, - }); - }; - - const handleChange = (e: any) => { - setFormData(prev => ({ - ...prev, - [e.target.name]: e.target.value, - })); - }; +const SettingsPage = () => { return (
Your Settings
-
- - - - - -
- -
-
-
- -
- + +
+
); diff --git a/components/account/FormHead.tsx b/components/account/FormHead.tsx new file mode 100644 index 00000000..d85e4079 --- /dev/null +++ b/components/account/FormHead.tsx @@ -0,0 +1,12 @@ +import { question, title } from '@/styles/account.css'; + +const FormHead = ({ titleText, questionText }: { titleText: string; questionText: string }) => { + return ( + <> +
{titleText}
+
{questionText}
+ + ); +}; + +export default FormHead; diff --git a/components/account/SignInForm.tsx b/components/account/SignInForm.tsx new file mode 100644 index 00000000..074143ae --- /dev/null +++ b/components/account/SignInForm.tsx @@ -0,0 +1,75 @@ +'use client'; +import useAuth from '@/hooks/useAuth'; +import useUserStore from '@/stores/useUserStore'; +import { form } from '@/styles/account.css'; +import { fillGreenButton, input } from '@/styles/common.css'; +import { buttonBox } from '@/styles/layout.css'; +import { LoginUser, UserResponse } from '@/types/api/users'; +import { UserAction } from '@/types/store/userStore'; +import { useRouter } from 'next/navigation'; +import { ChangeEvent, FormEvent, useState } from 'react'; + +const SignInForm = () => { + const router = useRouter(); + const { saveUserInfo } = useUserStore() as UserAction; + + const [loginUser, setLoginUser] = useState({ + email: '', + password: '', + }); + + const loginSuccess = (res: UserResponse) => { + saveUserInfo({ ...res.user }); + router.push('/'); + }; + + const loginError = (err: Error) => { + console.error(err.message); + alert('이메일 또는 비밀번호가 잘못되었습니다.'); + }; + + const { login } = useAuth({ loginSuccess, loginError }); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + login({ + ...loginUser, + }); + }; + + const handleChange = (e: ChangeEvent) => { + const { name, value } = e.target; + setLoginUser(prev => ({ + ...prev, + [name]: value, + })); + }; + + return ( +
+ + +
+ +
+
+ ); +}; + +export default SignInForm; diff --git a/components/account/SignUpForm.tsx b/components/account/SignUpForm.tsx new file mode 100644 index 00000000..d8adefd0 --- /dev/null +++ b/components/account/SignUpForm.tsx @@ -0,0 +1,93 @@ +'use client'; + +import useAuth from '@/hooks/useAuth'; +import useUserStore from '@/stores/useUserStore'; +import { form } from '@/styles/account.css'; +import { fillGreenButton, input } from '@/styles/common.css'; +import { buttonBox } from '@/styles/layout.css'; +import { NewUser } from '@/types/api/users'; +import { UserAction } from '@/types/store/userStore'; + +import { useRouter } from 'next/navigation'; +import { ChangeEvent, FormEvent, useState } from 'react'; + +const SignUpForm = () => { + const router = useRouter(); + const { saveUserInfo } = useUserStore() as UserAction; + const [newUser, setNewUser] = useState({ + username: '', + email: '', + password: '', + }); + + const signupSuccess = (res: any) => { + saveUserInfo({ + ...res.user, + }); + router.push('/'); + }; + + const signupError = () => { + alert('회원가입에 실패했습니다.'); + setNewUser({ + username: '', + email: '', + password: '', + }); + }; + + const { signup } = useAuth({ signupSuccess, signupError }); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + + signup({ + ...newUser, + }); + }; + + const handleChange = (e: ChangeEvent) => { + const { name, value } = e.target; + setNewUser(prev => ({ + ...prev, + [name]: value, + })); + }; + + return ( +
+ + + +
+ +
+
+ ); +}; + +export default SignUpForm; diff --git a/components/article/ArticleDeleteButton.tsx b/components/article/ArticleDeleteButton.tsx new file mode 100644 index 00000000..9df24e7e --- /dev/null +++ b/components/article/ArticleDeleteButton.tsx @@ -0,0 +1,18 @@ +'use client'; +import Button from '@/composables/Button'; +import { useRouter } from 'next/navigation'; + +const ArticleDeleteButton = ({ slug }: { slug: string }) => { + const router = useRouter(); + const handleButtonClick = async () => { + await fetch(`/api/articles/${slug}`, { method: 'DELETE' }).then(res => res.json()); + router.push('/'); + }; + return ( + + ); +}; + +export default ArticleDeleteButton; diff --git a/components/article/ArticleEditButton.tsx b/components/article/ArticleEditButton.tsx new file mode 100644 index 00000000..aee4c79a --- /dev/null +++ b/components/article/ArticleEditButton.tsx @@ -0,0 +1,17 @@ +'use client'; +import Button from '@/composables/Button'; +import { useRouter } from 'next/navigation'; + +const ArticleEditButton = ({ slug }: { slug: string }) => { + const router = useRouter(); + const handleButtonClick = () => { + router.push(`/editor/${slug}`); + }; + return ( + + ); +}; + +export default ArticleEditButton; diff --git a/components/article/ArticleList.tsx b/components/article/ArticleList.tsx index 675cf3ed..33c175b0 100644 --- a/components/article/ArticleList.tsx +++ b/components/article/ArticleList.tsx @@ -1,44 +1,35 @@ -// 'use client'; -import { ArticleAPI, fetchArticles } from '@/services/articles'; +'use client'; + import ArticlePreview from './ArticlePreview'; -import { useInfiniteQuery } from '@tanstack/react-query'; import React, { useRef } from 'react'; +import { flexCenter } from '@/styles/common.css'; +import useCurrentTab from '@/stores/useCurrentTab'; +import useArticles from '@/hooks/useArticles'; +import { Article } from '@/types/api/articles'; +type Props = { + username?: string; +}; +const ArticleList = ({ username }: Props) => { + const targetRef = useRef(null); + const { tab } = useCurrentTab(); + const { articlesData } = useArticles({ targetRef, tab, username }); -const ArticleList = async () => { - const { articles } = await ArticleAPI.all(1); - - // const { data, error, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage, status } = useInfiniteQuery({ - // queryKey: ['articles'], - // queryFn: ({ pageParam = 1 }) => ArticleAPI.all(pageParam), - // getNextPageParam: (lastPage, pages) => { - // if (5 > page.current) { - // return page.current; - // } - // return undefined; - // }, - // retry: false, - // onSuccess: () => { - // console.log('성공' + page.current); - - // page.current++; - // }, - // onError: () => {}, - // }); - - // return ( - //
- // - // {data?.pages.map((group, i) => ( - //
- // {group.articles.map(article => ( - // - // ))} - //
- // ))} - //
- // ); - - return
{articles?.map(article => )}
; + return ( +
+ {articlesData?.pages?.at(0)?.articles?.length === 0 ? ( + '데이터가 없습니다.' + ) : ( +
+ {articlesData?.pages.map((group, i) => ( +
+ {group?.articles?.map((article: Article) => )} +
+ ))} +
+ )} +
+
+ ); }; export default ArticleList; diff --git a/components/article/ArticlePreview.tsx b/components/article/ArticlePreview.tsx index fe220c37..8e85d993 100644 --- a/components/article/ArticlePreview.tsx +++ b/components/article/ArticlePreview.tsx @@ -5,8 +5,10 @@ import TagList from '../tags/TagList'; import { useRouter } from 'next/navigation'; import { FillHeartIcon } from '@/composables/icons'; import { fillGreenButton, flex, flexBetween, greenButton } from '@/styles/common.css'; -import { useMutation } from '@tanstack/react-query'; -import { favoriteArticleAPI, unFavoriteArticleAPI } from '@/services/favorites'; +import { useQueryClient } from '@tanstack/react-query'; +import useCurrentTab from '@/stores/useCurrentTab'; +import useArticles from '@/hooks/useArticles'; + type Props = { article: any; }; @@ -14,23 +16,34 @@ const ArticlePreview = ({ article: { title, description, favorited, favoritesCount, tagList, author, createdAt, slug }, }: Props) => { const router = useRouter(); + const { tab } = useCurrentTab(); + + const queryClient = useQueryClient(); + + const onSuccess = () => { + queryClient.invalidateQueries({ queryKey: ['articles', tab] }); + }; + + const onError = () => { + // 권한이 없을 경우 login 페이지로 이동 + router.push('/login'); + }; + + const { favorite, unFavorite } = useArticles({ onSuccess, onError }); + + const handleButtonClick = (slug: string) => { + if (favorited) { + unFavorite(slug); + } else { + favorite(slug); + } + }; - const { mutate } = useMutation({ - mutationFn: favorited ? unFavoriteArticleAPI : favoriteArticleAPI, - onError: err => { - console.error(err); - }, - onSuccess: res => { - console.log(res); - - console.log('좋아요!'); - }, - }); return (
- +
+
+ ); +}; + +export default EditForm; diff --git a/components/editor/TagInput.tsx b/components/editor/TagInput.tsx index d3cc7c1f..55232935 100644 --- a/components/editor/TagInput.tsx +++ b/components/editor/TagInput.tsx @@ -5,22 +5,22 @@ import { useState } from 'react'; import TagList from '../tags/TagList'; type Props = { - setFormData: any; + appendTag: (tag: string) => void; }; -const TagInput = ({ setFormData }: Props) => { +const TagInput = ({ appendTag }: Props) => { const [value, setValue] = useState(''); const [tags, setTags] = useState([]); const handleKeyDown = (e: any) => { if (e.key === 'Enter' && value.trim() !== '') { - setFormData(prevData => ({ ...prevData, tagList: tags })); + appendTag(value); setTags(prev => [...prev, value]); setValue(''); } }; - const handleClick = (tag: string) => { + const handleTagClick = (tag: string) => { setTags((prevTags: string[]) => prevTags.filter(prevTag => prevTag !== tag)); }; @@ -34,7 +34,7 @@ const TagInput = ({ setFormData }: Props) => { onKeyDown={handleKeyDown} className={input} /> - + ); }; diff --git a/components/layouts/Banner.tsx b/components/layouts/Banner.tsx index aeaaa88a..fd1ff029 100644 --- a/components/layouts/Banner.tsx +++ b/components/layouts/Banner.tsx @@ -1,12 +1,21 @@ +'use client'; +import useUserStore from '@/stores/useUserStore'; import { backgroundBlack, backgroundGreen, container } from '@/styles/common.css'; import { banner } from '@/styles/home.css'; +import { User } from '@/types/api/users'; + import { ReactNode } from 'react'; type Props = { children: ReactNode; background: 'green' | 'black'; }; const Banner = ({ children, background }: Props) => { - return ( + const { email } = useUserStore() as User; + return email && background === 'green' ? ( +
+
+
+ ) : (
{children}
diff --git a/components/layouts/Header.tsx b/components/layouts/Header.tsx index 6330ec81..7644beb8 100644 --- a/components/layouts/Header.tsx +++ b/components/layouts/Header.tsx @@ -1,4 +1,3 @@ -'use client'; import { header, logo } from '@/styles/layout.css'; import Link from 'next/link'; import React from 'react'; diff --git a/components/layouts/NavBar.tsx b/components/layouts/NavBar.tsx index 66c06af6..d13b5dc5 100644 --- a/components/layouts/NavBar.tsx +++ b/components/layouts/NavBar.tsx @@ -1,12 +1,12 @@ 'use client'; import * as styles from '@/styles/layout.css'; -import useUserStore from '@/stores/useUserStore'; import { usePathname } from 'next/navigation'; - import Link from 'next/link'; import { userImageSm } from '@/styles/profile.css'; import Image from 'next/image'; import { EditIcon, SettingIcon } from '@/composables/icons'; +import useUserStore from '@/stores/useUserStore'; +import { User } from '@/types/api/users'; const NAVS = [ { @@ -36,7 +36,7 @@ const NAVS = [ ]; const NavBar = () => { - const { username, image } = useUserStore(); + const { username, image } = useUserStore() as User; const pathname = usePathname(); return ( diff --git a/components/layouts/SideBar.tsx b/components/layouts/SideBar.tsx index 914ef61a..bc87efe2 100644 --- a/components/layouts/SideBar.tsx +++ b/components/layouts/SideBar.tsx @@ -1,16 +1,29 @@ -import React from 'react'; +'use client'; import TagList from '../tags/TagList'; import { sideBar, sideBarText } from '@/styles/layout.css'; import { sidePadding } from '@/styles/common.css'; -import { http } from '@/libs/http'; +import useCurrentTab from '@/stores/useCurrentTab'; +import { useQuery } from '@tanstack/react-query'; +import { getTags } from '@/services/tags'; + +const SideBar = () => { + const { data: tags } = useQuery({ + queryKey: ['tags'], + queryFn: getTags, + select: res => res.tags, + }); + + const { setTab } = useCurrentTab(); + + const handleTagClick = (tag: string) => { + setTab(tag); + }; -const SideBar = async () => { - const { tags } = await http.get('https://api.realworld.io/api/tags'); return (

Popular Tags

- +
); diff --git a/components/profile/ProfileBox.tsx b/components/profile/ProfileBox.tsx new file mode 100644 index 00000000..c8ac50f6 --- /dev/null +++ b/components/profile/ProfileBox.tsx @@ -0,0 +1,33 @@ +import { SettingIcon } from '@/composables/icons'; +import useUserStore from '@/stores/useUserStore'; +import { settingButton, userBlock, userImage, userInfo, userName } from '@/styles/profile.css'; +import Image from 'next/image'; +import Link from 'next/link'; +import FollowButton from '../user/FollowButton'; +import { User } from '@/types/api/users'; +type Props = { + image: string; + username: string; + following: boolean; +}; +const ProfileBox = ({ image, username, following }: Props) => { + const { username: currentUsername } = useUserStore() as User; + return ( +
+
+ Profile +
{username}
+ {currentUsername === username ? ( + + +   Edit Profile Settings + + ) : ( + + )} +
+
+ ); +}; + +export default ProfileBox; diff --git a/components/settings/LogoutButton.tsx b/components/settings/LogoutButton.tsx new file mode 100644 index 00000000..60896e9c --- /dev/null +++ b/components/settings/LogoutButton.tsx @@ -0,0 +1,32 @@ +'use client'; +import useAuth from '@/hooks/useAuth'; +import useUserStore from '@/stores/useUserStore'; +import { flex } from '@/styles/common.css'; +import { logoutButton } from '@/styles/settings.css'; +import { UserAction } from '@/types/store/userStore'; + +import { useRouter } from 'next/navigation'; + +const LogoutButton = () => { + const router = useRouter(); + const { logout } = useUserStore() as UserAction; + const signoutSuccess = () => { + logout(); + router.push('/login'); + }; + + const signoutError = (err: Error) => { + console.error(err.message); + }; + + const { signOut } = useAuth({ signoutSuccess, signoutError }); + return ( +
+ +
+ ); +}; + +export default LogoutButton; diff --git a/components/settings/SettingForm.tsx b/components/settings/SettingForm.tsx new file mode 100644 index 00000000..188a5e3f --- /dev/null +++ b/components/settings/SettingForm.tsx @@ -0,0 +1,106 @@ +'use client'; +import useUserStore from '@/stores/useUserStore'; +import { articleTextarea } from '@/styles/article.css'; +import { input } from '@/styles/common.css'; +import { settingForm, updateButton } from '@/styles/settings.css'; +import { User, UserAction } from '@/types'; +import { useMutation } from '@tanstack/react-query'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; + +const SettingForm = () => { + const router = useRouter(); + + const { updateUser, email, username, image, bio } = useUserStore() as User & UserAction; + + // 초기화 함수로 전환 + const [formData, setFormData] = useState({ + image, + username, + bio: bio ? bio : '', + email, + password: '', + }); + const { mutate, isLoading } = useMutation({ + mutationFn: (formData: any) => + fetch('/api/auth/user', { method: 'PUT', body: JSON.stringify(formData) }).then(res => res.json()), + onSuccess: () => { + updateUser({ + ...formData, + }); + router.push(`/@${username}`); + }, + onError: () => { + alert('실패'); + }, + }); + const handleSubmit = async (e: any) => { + e.preventDefault(); + + mutate({ + ...formData, + password: formData && formData.password, + }); + }; + + const handleChange = (e: any) => { + setFormData(prev => ({ + ...prev, + [e.target.name]: e.target.value, + })); + }; + return ( + + + + + + +
+ +
+ + ); +}; + +export default SettingForm; diff --git a/components/tags/Tag.tsx b/components/tags/Tag.tsx new file mode 100644 index 00000000..ba635744 --- /dev/null +++ b/components/tags/Tag.tsx @@ -0,0 +1,17 @@ +'use client'; +import { tagFill, tagItem } from '@/styles/layout.css'; + +type Props = { + tag: string; + onTagClick?: (tag: string) => void; +}; + +const Tag = ({ tag, onTagClick }: Props) => { + return ( +
  • onTagClick(tag) : () => console.log('없음')}> + {tag} +
  • + ); +}; + +export default Tag; diff --git a/components/tags/TagList.tsx b/components/tags/TagList.tsx index bb664cba..93af0d51 100644 --- a/components/tags/TagList.tsx +++ b/components/tags/TagList.tsx @@ -1,20 +1,12 @@ -'use client'; -import { tagFill, tagItem, tagList } from '@/styles/layout.css'; +import { tagList } from '@/styles/layout.css'; +import Tag from './Tag'; type Props = { tags: string[]; onClick?: (tag: string) => void; }; const TagList = ({ tags, onClick }: Props) => { - return ( -
      - {tags?.map((tag, index) => ( -
    • onClick(tag)}> - {tag} -
    • - ))} -
    - ); + return
      {tags?.map((tag, index) => )}
    ; }; export default TagList; diff --git a/components/user/FollowButton.tsx b/components/user/FollowButton.tsx index bf0863e0..490f7fa0 100644 --- a/components/user/FollowButton.tsx +++ b/components/user/FollowButton.tsx @@ -1,15 +1,39 @@ 'use client'; import Button from '@/composables/Button'; import { PlusIcon } from '@/composables/icons'; +import useProfile from '@/hooks/useProfile'; import { fontSize } from '@/styles/common.css'; +import { useQueryClient } from '@tanstack/react-query'; +import { useRouter } from 'next/navigation'; type Props = { author: any; + slug?: string; }; -const FollowButton = ({ author }: Props) => { +const FollowButton = ({ author: { username, following }, slug }: Props) => { + const router = useRouter(); + const queryClient = useQueryClient(); + + const onSuccess = () => { + queryClient.invalidateQueries(['article', slug]); + }; + + const onError = () => { + router.push('/login'); + }; + + const { follow, unFollow } = useProfile({ onSuccess, onError }); + + const handleButtonClick = (username: string) => { + if (following) { + unFollow(username); + } else { + follow(username); + } + }; + return ( - ); }; diff --git a/components/user/UserBox.tsx b/components/user/UserBox.tsx index ecc4ab60..ca8813db 100644 --- a/components/user/UserBox.tsx +++ b/components/user/UserBox.tsx @@ -4,16 +4,19 @@ import { circle } from '@/styles/common.css'; import { formatDate } from '@/utils'; import Image from 'next/image'; +import { useRouter } from 'next/navigation'; type Props = { - author: any; + author: { + username: string; + image: string; + }; createdAt: string; }; const UserBox = ({ author: { username, image }, createdAt }: Props) => { - const handleUserBoxClick = async () => { - console.log('클라 클릭'); - - await fetch(`/api/profile?username=${username}`); + const router = useRouter(); + const handleUserBoxClick = () => { + router.push(`/@${username}`); }; return (
    diff --git a/composables/Button.tsx b/composables/Button.tsx index 2bb3ec70..58abb5d4 100644 --- a/composables/Button.tsx +++ b/composables/Button.tsx @@ -1,6 +1,6 @@ 'use client'; -import { grayButton, greenButton } from '@/styles/common.css'; +import { grayButton, greenButton, redButton } from '@/styles/common.css'; import { Button } from '@/types'; const Button = ({ onClick, children, type }: Button) => { @@ -20,6 +20,9 @@ function getButtonStyle(type: string): React.JSX.Element { case 'gray': classType = grayButton; break; + case 'red': + classType = redButton; + break; } return classType; } diff --git a/constants/api.ts b/constants/api.ts new file mode 100644 index 00000000..80d8dd57 --- /dev/null +++ b/constants/api.ts @@ -0,0 +1,10 @@ +export const HTTP_METHOD = { + GET: 'GET', + POST: 'POST', + PUT: 'PUT', + DELETE: 'DELETE', +}; + +export const COMMON_HEADERS = { + 'Content-Type': 'application/json; charset=utf-8', +}; diff --git a/hooks/useArticles.ts b/hooks/useArticles.ts new file mode 100644 index 00000000..3387791c --- /dev/null +++ b/hooks/useArticles.ts @@ -0,0 +1,84 @@ +import { getArticlesWithTagAPI } from '@/services/articles'; +import { useInfiniteQuery, useMutation } from '@tanstack/react-query'; +import { RefObject } from 'react'; +import useIntersectionObserver from './useIntersectionObserver'; + +const useArticles = ({ + targetRef, + tab = 'global', + username = '', + onSuccess, + onError, +}: { + targetRef?: RefObject | undefined; + tab?: string; + username?: string; + onSuccess?: (res?: any) => void; + onError?: (err?: any) => void; +}) => { + const { + data: articlesData, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useInfiniteQuery({ + queryKey: ['articles', tab], + queryFn: async ({ pageParam = 0 }) => { + switch (tab) { + case 'global': + return await fetch(`http://localhost:3000/api/articles?page=${pageParam}`).then(res => res.json()); + case 'my': + return await fetch(`http://localhost:3000/api/articles/my?username=${username}&page=${pageParam}`).then(res => + res.json() + ); + case 'favorited': + return await fetch(`http://localhost:3000/api/articles?username=${username}&page=${pageParam}`).then(res => + res.json() + ); + case 'your': + return await fetch(`http://localhost:3000/api/articles/feed?page=${pageParam}`).then(res => res.json()); + default: + return await getArticlesWithTagAPI(tab, pageParam); + } + }, + getNextPageParam: (lastPage, pages) => { + const totalPage = Math.ceil(lastPage.articlesCount / 10); + let currentPage = pages.length; + if (lastPage.articlesCount < 11 || totalPage < pages.length) { + return undefined; + } + + return currentPage++; + }, + enabled: !!targetRef, + }); + + const { mutate: favorite } = useMutation({ + mutationFn: async (slug: string) => { + return await fetch(`/api/articles/favorite/${slug}`, { method: 'POST' }).then(res => res.json()); + }, + onSuccess, + onError, + }); + + const { mutate: unFavorite } = useMutation({ + mutationFn: async (slug: string) => { + return await fetch(`/api/articles/favorite/${slug}`, { method: 'DELETE' }).then(res => res.json()); + }, + onSuccess, + onError, + }); + + const nextPage = () => { + if (!hasNextPage || isFetchingNextPage) { + return; + } + fetchNextPage(); + }; + + useIntersectionObserver(nextPage, targetRef); + + return { articlesData, favorite, unFavorite }; +}; + +export default useArticles; diff --git a/hooks/useAuth.ts b/hooks/useAuth.ts new file mode 100644 index 00000000..fa8bdf6f --- /dev/null +++ b/hooks/useAuth.ts @@ -0,0 +1,50 @@ +import { LoginUser, NewUser, UserResponse } from '@/types/api/users'; +import { useMutation } from '@tanstack/react-query'; +// 로그인 / 로그아웃 / 회원가입 / 로그인 확인 +const useAuth = ({ + loginSuccess, + loginError, + signupSuccess, + signupError, + signoutSuccess, + signoutError, +}: { + loginSuccess?: (res: UserResponse) => void; + loginError?: (err: Error) => void; + signupSuccess?: (res: UserResponse) => void; + signupError?: (err: Error) => void; + signoutSuccess?: (res: UserResponse) => void; + signoutError?: (err: Error) => void; +}) => { + // 그냥 onSuccess랑 onError로 다 해결할까? login, signup, signout을 같은 컴포넌트에서 + // 사용할 일이 있을까? + + // 로그인 + const { mutate: login } = useMutation({ + mutationFn: async (loginUser: LoginUser) => + await fetch('/api/auth/login', { method: 'POST', body: JSON.stringify({ ...loginUser }) }).then(res => + res.json() + ), + onSuccess: loginSuccess, + onError: loginError, + }); + + // 회원가입 + const { mutate: signup } = useMutation({ + mutationFn: async (newUser: NewUser) => + await fetch('/api/auth/signup', { method: 'POST', body: JSON.stringify({ ...newUser }) }).then(res => res.json()), + onSuccess: signupSuccess, + onError: signupError, + }); + + // 로그아웃 + const { mutate: signOut } = useMutation({ + mutationFn: async () => await fetch('/api/auth/logout').then(res => res.json()), + onSuccess: signoutSuccess, + onError: signoutError, + }); + + return { login, signup, signOut }; +}; + +export default useAuth; diff --git a/hooks/useIntersectionObserver.ts b/hooks/useIntersectionObserver.ts new file mode 100644 index 00000000..f7a67492 --- /dev/null +++ b/hooks/useIntersectionObserver.ts @@ -0,0 +1,29 @@ +import { RefObject, useEffect } from 'react'; + +const useIntersectionObserver = (cb: () => void, ref: RefObject) => { + useEffect(() => { + const observer = new IntersectionObserver( + entries => { + entries.forEach(entry => { + if (!entry.isIntersecting) return; + cb(); + }); + }, + { threshold: 0.4 } //40%가 보일때를 기본 값으로 설정 했습니다. + ); + + const currentRef = ref?.current; + + if (currentRef) { + observer.observe(currentRef); + } + + return () => { + if (currentRef) { + observer.unobserve(currentRef); + } + }; + }, [cb, ref]); +}; + +export default useIntersectionObserver; diff --git a/hooks/useProfile.ts b/hooks/useProfile.ts new file mode 100644 index 00000000..dc00cca4 --- /dev/null +++ b/hooks/useProfile.ts @@ -0,0 +1,38 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; + +const useProfile = ({ + username, + onSuccess, + onError, +}: { + username?: string; + onSuccess?: (res: any) => void; + onError?: (err: any) => void; +}) => { + const { data: profile } = useQuery({ + queryKey: ['profile', username], + queryFn: () => fetch(`/api/profiles/${username}`, { method: 'GET' }).then(res => res.json()), + enabled: !!username, + select: res => res.response.profile, + }); + + const { mutate: follow } = useMutation({ + mutationFn: async (username: string) => { + return fetch(`/api/profiles/${username}`, { method: 'POST' }).then(res => res.json()); + }, + onSuccess, + onError, + }); + + const { mutate: unFollow } = useMutation({ + mutationFn: async (username: string) => { + return fetch(`/api/profiles/${username}`, { method: 'DELETE' }).then(res => res.json()); + }, + onSuccess, + onError, + }); + + return { profile, follow, unFollow }; +}; + +export default useProfile; diff --git a/libs/Providers.tsx b/libs/Providers.tsx index 389bace9..2d7f1950 100644 --- a/libs/Providers.tsx +++ b/libs/Providers.tsx @@ -13,6 +13,8 @@ export default function Providers({ children }: PropsWithChildren) { defaultOptions: { queries: { suspense: true, + refetchOnWindowFocus: false, + retry: false, }, }, }) diff --git a/libs/getQueryClient.ts b/libs/getQueryClient.ts index 9a67a9a6..b78ce01e 100644 --- a/libs/getQueryClient.ts +++ b/libs/getQueryClient.ts @@ -1,14 +1,5 @@ import { QueryClient } from '@tanstack/query-core'; import { cache } from 'react'; -const getQueryClient = cache( - () => - new QueryClient({ - defaultOptions: { - queries: { - suspense: true, - }, - }, - }) -); +const getQueryClient = cache(() => new QueryClient()); export default getQueryClient; diff --git a/libs/http.ts b/libs/http.ts deleted file mode 100644 index 8fffadf8..00000000 --- a/libs/http.ts +++ /dev/null @@ -1,27 +0,0 @@ -import Axios from 'axios'; - -const axios = Axios.create(); - -export const http = { - get: function get(url: string) { - return axios.get(url).then(res => res.data); - }, - post: function post(url: string, body?: Request) { - return axios - .post(url, body) - .then(res => { - console.log('성공'); - - return res.data; - }) - .catch(err => { - console.log('에러 발생'); - }); - }, - put: function put(url: string, body?: Request) { - return axios.put(url, body).then(res => res.data); - }, - delete: function remove(url: string) { - return axios.delete(url).then(res => res.data); - }, -}; diff --git a/middleware.ts b/middleware.ts index 18d275d8..49573027 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,20 +1,32 @@ import { NextRequest, NextResponse } from 'next/server'; -export async function middleware(req: NextRequest) { - const token = true; +export async function middleware(request: NextRequest) { + const path = request.nextUrl.pathname; + const isPublic = path === '/login' || path === '/register'; - if (!token) { - // 아직 보류 - if (req.nextUrl.pathname.startsWith('/api')) { - return new NextResponse('Authentiction Error', { status: 401 }); - } - // 권한 문제 - return NextResponse.redirect('http://localhost:3000/login'); + const token = request.cookies.get('token')?.value || ''; + + if (path.includes('/api') && !token) { + return new NextResponse('Authentication Error', { status: 401 }); + } + + if (isPublic && token) { + return NextResponse.redirect(new URL('/', request.nextUrl)); } - return NextResponse.next(); + if (!isPublic && !token) { + return NextResponse.redirect(new URL('/login', request.nextUrl)); + } } export const config = { - matcher: ['/settings', '/editor'], + matcher: [ + '/settings', + '/editor', + '/login', + '/register', + '/api/user', + '/api/articles/favorite/:path*', + '/api/profiles', + ], }; diff --git a/package-lock.json b/package-lock.json index 0830a7e3..55188673 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@tanstack/react-query": "^4.33.0", "@vanilla-extract/css": "^1.13.0", "axios": "^1.5.0", + "jwt-decode": "^3.1.2", "next": "^13.4.19", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -4698,6 +4699,11 @@ "node": ">=4.0" } }, + "node_modules/jwt-decode": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz", + "integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==" + }, "node_modules/keyv": { "version": "4.5.3", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.3.tgz", @@ -10506,6 +10512,11 @@ "object.values": "^1.1.6" } }, + "jwt-decode": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz", + "integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==" + }, "keyv": { "version": "4.5.3", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.3.tgz", diff --git a/package.json b/package.json index 6ecc4e70..a9f79fb3 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "@tanstack/react-query": "^4.33.0", "@vanilla-extract/css": "^1.13.0", "axios": "^1.5.0", + "jwt-decode": "^3.1.2", "next": "^13.4.19", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/services/articles.ts b/services/articles.ts index e2f3bf45..b4e8ebc0 100644 --- a/services/articles.ts +++ b/services/articles.ts @@ -1,96 +1,54 @@ -import { API_BASE_URL } from '@/constants/env'; -import { http } from '@/libs/http'; -import { Article, NewArticle } from '@/types'; +import { http } from '@/utils/http'; -const ArticleAPI = { - all: async (offset?: number, limit = 10) => { - return fetch(`${API_BASE_URL}/articles?limit=${limit}&offset=${offset ? offset * limit : 0}`, { - method: 'GET', - headers: { 'Content-Type': 'application/json; charset=utf-8' }, - }).then(res => { - if (!(res.status === 200)) { - throw new Error('Error'); - } - return res.json(); - }); - }, - byTag: async (tag: string, offset = 0, limit = 10) => { - return fetch(`${API_BASE_URL}/articles?tag=${tag}&limit=${limit}&offset=${offset ? offset * limit : 0}`, { - method: 'GET', - headers: { 'Content-Type': 'application/json; charset=utf-8' }, - }).then(res => { - if (!(res.status === 200)) { - throw new Error('Error'); - } - return res.json(); - }); - }, - favorite: (slug: string) => { - return fetch(`${API_BASE_URL}/articles/${slug}/favorite`, { - method: 'POST', - headers: { 'Content-Type': 'application/json; charset=utf-8' }, - }).then(res => { - if (!(res.status === 201)) { - throw new Error('Error'); - } - return res.json(); - }); - }, - unFavorite: (slug: string) => { - return fetch(`${API_BASE_URL}/articles/${slug}/favorite`, { - method: 'DELETE', - headers: { 'Content-Type': 'application/json; charset=utf-8' }, - }).then(res => { - if (!(res.status === 201)) { - throw new Error('Error'); - } - return res.json(); - }); - }, +const getArticlesAPI = (auth: string, offset = 0, limit = 20) => { + return http.get(`/articles?limit=${limit}&offset=${offset ? offset * limit : 0}`, { + headers: { + 'Content-Type': 'application/json; charset=utf-8', + Authorization: `Token ${auth}`, + }, + }); }; -// 전제 Article 조회 -const fetchArticles = (limit = 20) => { - return http.get(`https://api.realworld.io/api/articles?limit=${limit}`); +const getArticlesWithTagAPI = (tag: string, offset = 0, limit = 10) => { + return http.get(`/articles?tag=${tag}&limit=${limit}&offset=${offset ? offset * limit : 0}`); }; -// Tag로 Article 조회 -const fetchArticlesWithTag = (tag: string, limit = 20) => { - return http.get(`https://api.realworld.io/api/articles?limit=${limit}&tag=${tag}`); +const getArticlesWithAuthorAPI = (username: string, auth: string, offset = 0, limit = 10) => { + return http.get(`/articles?author=${username}&limit=${limit}&offset=${offset ? offset * limit : 0}`, { + headers: { + 'Content-Type': 'application/json; charset=utf-8', + Authorization: `Token ${auth}`, + }, + }); }; -// Follow한 user의 Article 조회 -const fetchFollowArticles = async () => { - return await http.get('https://api.realworld.io/api/articles/feed'); +const getArticlesWithFavoritedAPI = (username: string, auth: string, offset = 0, limit = 10) => { + return http.get(`/articles?favorited=${username}&limit=${limit}&offset=${offset ? offset * limit : 0}`, { + headers: { + 'Content-Type': 'application/json; charset=utf-8', + Authorization: `Token ${auth}`, + }, + }); }; -// Article 작성 -const registerArticle = (article: NewArticle) => { - return http.post('https://api.realworld.io/api/articles', article); +const getArticlesFeed = (offset = 0, auth: string, limit = 10) => { + return http.get(`/articles/feed?limit=${limit}&offset=${offset ? offset * limit : 0}`, { + headers: { + 'Content-Type': 'application/json; charset=utf-8', + Authorization: `Token ${auth}`, + }, + }); }; -// Article 단건 조회 -const fetchArticle = async (slug: string): Promise
    => { - return http.get(`https://api.realworld.io/api/articles/${slug}`).then(res => res.article); -}; - -// Article 수정 -const updateArticle = (slug: string) => { - return http.put(`https://api.realworld.io/api/articles/${slug}`); -}; - -// Article 삭제 -const deleteArticle = (slug: string) => { - return http.delete(`https://api.realworld.io/api/articles/${slug}`); +const getArticleAPI = (slug: string) => { + return http.get(`/articles/${slug}`); }; export { - fetchArticles, - fetchArticlesWithTag, - fetchFollowArticles, - fetchArticle, - registerArticle, - updateArticle, - deleteArticle, - ArticleAPI, + getArticlesAPI, + getArticlesWithAuthorAPI, + getArticlesWithFavoritedAPI, + getArticlesWithTagAPI, + getArticlesFeed, + getArticleAPI, }; diff --git a/services/comments.ts b/services/comments.ts index 79fad89f..9885f3dd 100644 --- a/services/comments.ts +++ b/services/comments.ts @@ -1,15 +1,15 @@ -import { http } from '@/libs/http'; +import { http } from '@/utils/http'; const getComments = (slug: string) => { - return http.get(`https://api.realworld.io/api/articles/${slug}/comments`); + return http.get(`/articles/${slug}/comments`); }; const createComment = (slug: string) => { - return http.post(`https://api.realworld.io/api/articles/${slug}/comments`); + return http.post(`/articles/${slug}/comments`); }; const deleteComment = (slug: string, id: string) => { - return http.delete(`https://api.realworld.io/api/articles/${slug}/comments/${id}`); + return http.delete(`/articles/${slug}/comments/${id}`); }; export { getComments, createComment, deleteComment }; diff --git a/services/favorites.ts b/services/favorites.ts deleted file mode 100644 index ebbcb060..00000000 --- a/services/favorites.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { API_BASE_URL } from '@/constants/env'; -import { getCookie } from '@/utils/cookies'; - -// Article 좋아요 -const favoriteArticleAPI = async (slug: string) => { - const accessToken = getCookie('token'); - console.log(accessToken); - - return fetch( - 'https://api.realworld.io/api/articles/Try-to-generate-the-TCP-bus-maybe-it-will-override-the-neural-bandwidth%21-120863/favorite', - { - method: 'POST', - headers: { - accept: 'application/json', - // 'Content-Type': 'application/json; charset=utf-8', - Authorization: `Token ${accessToken}`, - }, - } - ).then(res => { - if (!(res.status === 200)) { - throw new Error('Error'); - } - return res.json(); - }); -}; - -// Article 좋아요 취소 -const unFavoriteArticleAPI = async (slug: string) => { - const accessToken = getCookie('token'); - console.log(accessToken); - - return fetch(`${API_BASE_URL}/articles/${slug}/favorite`, { - method: 'DELETE', - headers: { 'Content-Type': 'application/json; charset=utf-8', Authorization: `Token ${accessToken}` }, - }).then(res => { - if (!(res.status === 200)) { - throw new Error('Error'); - } - return res.json(); - }); -}; - -export { favoriteArticleAPI, unFavoriteArticleAPI }; diff --git a/services/profile.ts b/services/profile.ts index 406e782e..69fd20f8 100644 --- a/services/profile.ts +++ b/services/profile.ts @@ -1,15 +1,7 @@ -import { http } from '@/libs/http'; +import { http } from '@/utils/http'; const getProfile = (username: string) => { - return http.get(`https://api.realworld.io/api/profiles/${username}`); + return http.get(`/profiles/${username}`); }; -const followUser = (username: string) => { - return http.post(`https://api.realworld.io/api/profiles/${username}`); -}; - -const unFollowUser = (username: string) => { - return http.delete(`https://api.realworld.io/api/profiles/${username}`); -}; - -export { getProfile, followUser, unFollowUser }; +export { getProfile }; diff --git a/services/tags.ts b/services/tags.ts new file mode 100644 index 00000000..79ed2801 --- /dev/null +++ b/services/tags.ts @@ -0,0 +1,5 @@ +import { http } from '@/utils/http'; + +export const getTags = async () => { + return await http.get('/tags'); +}; diff --git a/services/users.ts b/services/users.ts index f3daa38b..6ea24633 100644 --- a/services/users.ts +++ b/services/users.ts @@ -1,66 +1,36 @@ -import { API_BASE_URL } from '@/constants/env'; -import { LoginUser, NewUser, UpdateUser } from '@/types'; -import { getCookie } from '@/utils/cookies'; -import Error from 'next/error'; +import { LoginUser, NewUser, UpdateUser } from '@/types/api/users'; +import { http } from '@/utils/http'; -// Register a new user +// 회원가입 const registerUserAPI = async (user: NewUser) => { - console.log(user); - - return fetch(`${API_BASE_URL}/users`, { - method: 'POST', - headers: { 'Content-Type': 'application/json; charset=utf-8' }, - body: JSON.stringify({ user }), - }).then(res => { - if (!(res.status === 201)) { - throw new Error('Error'); - } - return res.json(); - }); + return http.post('/users', { user }); }; -// Login for existing user +// 로그인 const loginAPI = async (user: LoginUser) => { - return fetch(`${API_BASE_URL}/users/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json; charset=utf-8' }, - body: JSON.stringify({ user }), - }).then(res => { - if (!(res.status === 200)) { - throw new Error('Error'); - } - return res.json(); - }); + return http.post('/users/login', { user }); }; -// Updated user information for current user -const updateUserAPI = async (user: UpdateUser) => { - const accessToken = getCookie('token'); - console.log(accessToken); - - return fetch(`${API_BASE_URL}/user`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json; charset=utf-8', Authorization: `Token ${accessToken}` }, - body: JSON.stringify({ user }), - }).then(res => { - if (!(res.status === 200)) { - throw new Error('Error'); +// 회원정보 수정 +const updateUserAPI = async (user: UpdateUser, auth: string) => { + return http.put( + '/user', + { user }, + { + headers: { + Authorization: `Token ${auth}`, + }, } - return res.json(); - }); + ); }; -// Gets the currently logged-in user -const getUserAPI = async () => { - const accessToken = getCookie('token'); - return fetch(`${API_BASE_URL}/user`, { - method: 'GET', - headers: { 'Content-Type': 'application/json; charset=utf-8', Authorization: `Token ${accessToken}` }, - }).then(res => { - if (!(res.status === 200)) { - throw new Error('Error'); - } - return res.json(); +// 현재 유저 조회 +const getUserAPI = async (auth: string) => { + return http.get('/user', { + headers: { + 'Content-Type': 'application/json; charset=utf-8', + Authorization: `Token ${auth}`, + }, }); }; diff --git a/stores/useCurrentTab.ts b/stores/useCurrentTab.ts new file mode 100644 index 00000000..87b154ea --- /dev/null +++ b/stores/useCurrentTab.ts @@ -0,0 +1,13 @@ +import { create } from 'zustand'; + +type CurrentTabState = { + tab: string; + setTab: (tab: string) => void; +}; + +const useCurrentTab = create(set => ({ + tab: 'global', + setTab: (tab: string) => set(() => ({ tab })), +})); + +export default useCurrentTab; diff --git a/stores/useUserStore.ts b/stores/useUserStore.ts index 4d682ed1..c5c10f93 100644 --- a/stores/useUserStore.ts +++ b/stores/useUserStore.ts @@ -1,24 +1,24 @@ -import { User, UserAction } from '@/types'; -import { setCookie } from '@/utils/cookies'; +import { User } from '@/types/api/users'; +import { UserAction } from '@/types/store/userStore'; import { create } from 'zustand'; import { persist } from 'zustand/middleware'; const initialState: User = { username: '', email: '', - token: '', bio: '', image: '', + password: '', }; const useUserStore = create( persist( set => ({ ...initialState, - login: user => { + saveUserInfo: user => { set(() => { - const { email, username, bio, image, token } = user; - setCookie('token', token, 60 * 60 * 24); + const { email, username, bio, image } = user; + return { email, username, @@ -29,13 +29,12 @@ const useUserStore = create( }, logout: () => { set(() => { - setCookie('token', '', 0); return { ...initialState, }; }); }, - updateUser: user => { + updateUser: (user: User) => { set(() => { return { ...user, diff --git a/styles/home.css.ts b/styles/home.css.ts index fd8e1213..c3bda877 100644 --- a/styles/home.css.ts +++ b/styles/home.css.ts @@ -1,8 +1,6 @@ import { style } from '@vanilla-extract/css'; -import { textCenter } from './common.css'; export const banner = style([ - textCenter, { padding: '2rem', color: '#fff', diff --git a/styles/settings.css.ts b/styles/settings.css.ts index 85572eda..e98f148c 100644 --- a/styles/settings.css.ts +++ b/styles/settings.css.ts @@ -1,5 +1,5 @@ import { style } from '@vanilla-extract/css'; -import { button, fillGreenButton, redButton } from './common.css'; +import { fillGreenButton, redButton } from './common.css'; export const settingBlock = style({ display: 'flex', diff --git a/types/api/articles.d.ts b/types/api/articles.d.ts new file mode 100644 index 00000000..8e8c59de --- /dev/null +++ b/types/api/articles.d.ts @@ -0,0 +1,18 @@ +import { Profile } from './profile'; + +export type Article = { + slug?: string; + title: string; + description?: string; + body: string; + tagList: string[]; + createdAt: string; + updateAt?: string; + favorited?: boolean; + favoritesCount: number; + author: Profile; +}; + +export type NewArticle = Pick; + +export type UpdateArticle = Pick; diff --git a/types/api/comment.d.ts b/types/api/comment.d.ts new file mode 100644 index 00000000..ae2dc8b1 --- /dev/null +++ b/types/api/comment.d.ts @@ -0,0 +1,21 @@ +import { Profile } from './profile'; + +export type Comment = { + id: number; + createdAt: string; + updatedAt: string; + // createdAt* [...] + // updatedAt + body: string; + author: Profile; +}; + +export type NewComment = Pick; + +export type CommentResponse = { + message: string; + success: boolean; + data: { + comments: Comment[]; + }; +}; diff --git a/types/api/profile.d.ts b/types/api/profile.d.ts new file mode 100644 index 00000000..5115420b --- /dev/null +++ b/types/api/profile.d.ts @@ -0,0 +1,6 @@ +export type Profile = { + username: string; + bio: string; + image: string; + following: boolean; +}; diff --git a/types/api/users.d.ts b/types/api/users.d.ts new file mode 100644 index 00000000..aeadf738 --- /dev/null +++ b/types/api/users.d.ts @@ -0,0 +1,18 @@ +export type User = { + email: string; + token?: string; + username: string; + bio: string; + image: string; + password?: string; +}; + +export type LoginUser = Pick; + +export type NewUser = Pick; + +export type UpdateUser = Omit; + +export type UserResponse = { + user: Omit; +}; diff --git a/types/index.ts b/types/index.ts deleted file mode 100644 index bfa5289c..00000000 --- a/types/index.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { ReactNode } from 'react'; - -export type Button = { - size?: string; - onClick: () => void; - children: ReactNode; - type: string; -}; - -export type Input = { - size: string; - placeholder: string; -}; - -export type LoginUser = { - email: string; - password: string; -}; - -export type NewUser = { - username: string; - email: string; - password: string; -}; - -export type User = { - email: string; - token: string; - username: string; - bio: string; - image: string; -}; - -export type UpdateUser = { - email: string; - password: string; - username: string; - bio: string; - image: string; -}; - -export type Profile = { - usename: string; - bio: string; - image: string; - following: boolean; -}; - -export type Article = { - slug?: string; - title: string; - description?: string; - body: string; - tagList: string[]; - createdAt: string; - updateAt?: string; - favorited?: boolean; - favoritesCount: number; - author: Profile; -}; - -export type NewArticle = { - title: string; - description: string; - body: string; - tagList: string; -}; - -export type UpdateArticle = { - title: string; - description: string; - body: string; -}; - -export type Comment = { - id: number; - createdAt: string; - updatedAt: string; - body: string; - author: Profile; -}; - -export type UserAction = { - login: (e: any) => void; - updateUser: () => void; - logout: () => void; - reset: () => void; -}; - -export type UserResponse = { - user: User; -}; diff --git a/types/props/composables.d.ts b/types/props/composables.d.ts new file mode 100644 index 00000000..d6274a07 --- /dev/null +++ b/types/props/composables.d.ts @@ -0,0 +1,13 @@ +import { ReactNode } from 'react'; + +export type Button = { + size?: string; + onClick: () => void; + children: ReactNode; + type: string; +}; + +export type Input = { + size: string; + placeholder: string; +}; diff --git a/types/store/userStore.d.ts b/types/store/userStore.d.ts new file mode 100644 index 00000000..fdd3c182 --- /dev/null +++ b/types/store/userStore.d.ts @@ -0,0 +1,7 @@ +import { User } from '../api/users'; + +export type UserAction = { + saveUserInfo: (user: User) => void; + updateUser: (user: User) => void; + logout: () => void; +}; diff --git a/utils/cookies.ts b/utils/cookies.ts index 9d8d5e63..8aa05a95 100644 --- a/utils/cookies.ts +++ b/utils/cookies.ts @@ -1,25 +1,12 @@ -'use client'; - -export const setCookie = (key: string, value: string, maxAge: number) => { - document.cookie = `${key}=${value};max-age=${maxAge}`; -}; - -export const getCookie = (key: string) => { - const cookie = document.cookie.split('; ').find(v => v.split('=')[0] === key); - - if (!cookie) { - return; +import jwtDecode from 'jwt-decode'; +import { NextRequest } from 'next/server'; + +export const getDataFromToken = (request: NextRequest) => { + try { + const token = request.cookies.get('token')?.value || ''; + const decodedToken = jwtDecode(token); + return decodedToken; + } catch (error: any) { + throw new Error(error.message); } - - const [, value] = cookie.split('='); - - return value; -}; - -export const deleteAllCookies = () => { - const cookies = document.cookie.split(';'); - - cookies.forEach((_, idx) => { - document.cookie = cookies[idx] + '=;expires=' + new Date(0).toUTCString(); - }); }; diff --git a/utils/http.ts b/utils/http.ts new file mode 100644 index 00000000..8edd6d4b --- /dev/null +++ b/utils/http.ts @@ -0,0 +1,44 @@ +import { API_BASE_URL } from '@/constants/env'; +export const http = { + request: async (url: string, method: string, body?: Request, options?: any) => { + const defaultOptions = { + method: method, + headers: { + 'Content-Type': 'application/json; charset=utf-8', + }, + body: body ? JSON.stringify(body) : undefined, + ...options, + }; + console.log(defaultOptions); + console.log(API_BASE_URL); + + try { + const response = await fetch(`${API_BASE_URL}${url}`, defaultOptions); + + if (!response.ok) { + const errorData = await response.json(); + + console.log(errorData); + + console.log('실패'); + + throw new Error(errorData.message || 'Request failed'); + } + + // console.log(response); + console.log(JSON.stringify(response)); + + console.log('성공'); + + return response.json(); + } catch (error) { + console.error(error); + // throw new Error('Request failed'); + } + }, + + get: (url: string, options?: any) => http.request(url, 'GET', undefined, options), + post: (url: string, body?: any, options?: any) => http.request(url, 'POST', body, options), + put: (url: string, body?: any, options?: any) => http.request(url, 'PUT', body, options), + delete: (url: string, options?: any) => http.request(url, 'DELETE', undefined, options), +};