[modern-react] 리액트 스터디 파일 추가
This commit is contained in:
28
modern-react/my-next/src/app/books/[[...keyword]]/layout.js
Normal file
28
modern-react/my-next/src/app/books/[[...keyword]]/layout.js
Normal file
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useRef } from 'react';
|
||||
|
||||
// "/books/keyword" 아래에 적용되는 레이아웃
|
||||
export default function BooksLayout({ children }) {
|
||||
const router = useRouter();
|
||||
const txtKeyword = useRef(null);
|
||||
// [검색] 버튼 클릭 시 '/books/keyword'로 리디렉션된다.
|
||||
const handleSearch = () => {
|
||||
router.push(`/books/${txtKeyword.current.value}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<form className="mt-2 mb-4">
|
||||
<input type="text" ref={txtKeyword}
|
||||
className="bg-gray-100 text-black border border-gray-600 rounded mr-2 px-2 py-2 focus:bg-white focus:outline-none focus:border-red-500" />
|
||||
<button type="button" onClick={handleSearch}
|
||||
className="bg-blue-600 text-white rounded px-4 py-2 hover:bg-blue-500">
|
||||
검색</button>
|
||||
</form>
|
||||
<hr />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export default function ApiLoading() {
|
||||
return (
|
||||
<div className="flex justify-center" aria-label="Now Loading...">
|
||||
<div className="animate-spin h-20 w-20 mt-5 border-8 border-blue-500 rounded-full border-b-transparent"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
modern-react/my-next/src/app/books/[[...keyword]]/page.js
Normal file
16
modern-react/my-next/src/app/books/[[...keyword]]/page.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import LinkedBookDetails from '@/components/LinkedBookDetails';
|
||||
import { getBooksByKeyword } from '@/lib/getter';
|
||||
|
||||
// 루트 매개변수 키워드 가져오기(기본값은 리액트)
|
||||
export default async function BookResult({ params: { keyword = '리액트' } }) {
|
||||
// 주어진 키워드로 도서 정보 검색
|
||||
const books = await getBooksByKeyword(keyword);
|
||||
return (
|
||||
<>
|
||||
{/* 획득한 도서 목록 보기 */}
|
||||
{books.map((b,i) => (
|
||||
<LinkedBookDetails book={b} index={i + 1} key={b.id} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
30
modern-react/my-next/src/app/edit/[id]/page.js
Normal file
30
modern-react/my-next/src/app/edit/[id]/page.js
Normal file
@@ -0,0 +1,30 @@
|
||||
// export default function EditPage({ params }) {
|
||||
// return <p>No. {params.id}의 리뷰를 표시하고 있다.</p>;
|
||||
// }
|
||||
|
||||
|
||||
|
||||
// Code 11-4-12
|
||||
import BookDetails from '@/components/BookDetails';
|
||||
import FormEdit from '@/components/FormEdit';
|
||||
import { getBookById, getReviewById } from '@/lib/getter';
|
||||
|
||||
export default async function EditPage({ params }) {
|
||||
const book = await getBookById(params.id);
|
||||
const review = await getReviewById(params.id);
|
||||
// 'YYYY-MM-DD' 형식의 날짜 생성
|
||||
const read = (review?.read || new Date()).toLocaleDateString('sv-SE');
|
||||
|
||||
// const read = (review?.read || new Date()).toLocaleDateString('ko-KR',
|
||||
// { year: 'numeric', month: '2-digit', day: '2-digit' }
|
||||
// ).replaceAll('/', '-')
|
||||
|
||||
return (
|
||||
<div id="form">
|
||||
<BookDetails book={book} />
|
||||
<hr />
|
||||
{/* 편집 양식 생성 */}
|
||||
<FormEdit src={{ id: book.id, read, memo: review?.memo }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
BIN
modern-react/my-next/src/app/favicon.ico
Normal file
BIN
modern-react/my-next/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
34
modern-react/my-next/src/app/globals.css
Normal file
34
modern-react/my-next/src/app/globals.css
Normal file
@@ -0,0 +1,34 @@
|
||||
/* Tailwind CSS에서 사용하는 스타일 정의 활성화 */
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--foreground-rgb: 0, 0, 0;
|
||||
--background-start-rgb: 214, 219, 220;
|
||||
--background-end-rgb: 255, 255, 255;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--foreground-rgb: 255, 255, 255;
|
||||
--background-start-rgb: 0, 0, 0;
|
||||
--background-end-rgb: 0, 0, 0;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
color: rgb(var(--foreground-rgb));
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
rgb(var(--background-end-rgb))
|
||||
)
|
||||
rgb(var(--background-start-rgb));
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
}
|
||||
65
modern-react/my-next/src/app/layout.js
Normal file
65
modern-react/my-next/src/app/layout.js
Normal file
@@ -0,0 +1,65 @@
|
||||
// import { Inter } from "next/font/google";
|
||||
// import "./globals.css";
|
||||
|
||||
// // 폰트 정보 설정
|
||||
// const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
// // 메타 정보 준비
|
||||
// export const metadata = {
|
||||
// title: "Create Next App",
|
||||
// description: "Generated by create next app",
|
||||
// };
|
||||
|
||||
// // 루트 레이아웃 준비
|
||||
// export default function RootLayout({ children }) {
|
||||
// return (
|
||||
// <html lang="en">
|
||||
// <body className={inter.className}>{children}</body>
|
||||
// </html>
|
||||
// );
|
||||
// }
|
||||
|
||||
|
||||
|
||||
// Code 11-4-1
|
||||
import Link from 'next/link';
|
||||
// Tailwind.css 설정 가져오기
|
||||
import './globals.css';
|
||||
import { Inconsolata } from 'next/font/google';
|
||||
|
||||
// 구글 폰트 활성화
|
||||
const fnt = Inconsolata({ subsets: ['latin'] })
|
||||
|
||||
// 메타데이터 정의
|
||||
export const metadata = {
|
||||
title: 'Reading Recorder',
|
||||
description: '내가 읽은 책을 기록하는 앱',
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html lang="ko">
|
||||
<body className={fnt.className}>
|
||||
<h1 className="text-4xl text-indigo-800 font-bold my-2">
|
||||
Reading Recorder</h1>
|
||||
{/* 공통 메뉴 준비 */}
|
||||
<ul className="flex bg-blue-600 mb-4 pl-2">
|
||||
<li className="block px-4 py-2 my-1 hover:bg-gray-100 rounded">
|
||||
<Link className="no-underline text-blue-300" href="/">
|
||||
Home</Link></li>
|
||||
<li className="block text-blue-300 px-4 py-2 my-1 hover:bg-gray-100 rounded">
|
||||
<Link className="no-underline text-blue-300" href="/books">
|
||||
Search</Link></li>
|
||||
<li className="block text-blue-300 px-4 py-2 my-1 hover:bg-gray-100 rounded">
|
||||
<a className="no-underline text-blue-300"
|
||||
href="https://wikibook.co.kr/support/contact/" target="_blank">Support</a></li>
|
||||
</ul>
|
||||
{/* 페이지 구성 요소를 반영하는 영역 */}
|
||||
<div className="ml-2">
|
||||
{children}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
39
modern-react/my-next/src/app/page.js
Normal file
39
modern-react/my-next/src/app/page.js
Normal file
@@ -0,0 +1,39 @@
|
||||
// import { getAllReviews } from '@/lib/getter';
|
||||
// import LinkedBookDetails from '@/components/LinkedBookDetails';
|
||||
|
||||
// // 항상 최신 정보 얻기
|
||||
// export const dynamic = 'force-dynamic';
|
||||
// export default async function Home() {
|
||||
// // 모든 리뷰 정보 얻기
|
||||
// const reviews = await getAllReviews();
|
||||
// return (
|
||||
// <>
|
||||
// {/* 획득한 리뷰 정보를 바탕으로 리스트 생성 */}
|
||||
// {reviews.map((b, i) => (
|
||||
// <LinkedBookDetails book={b} index={i + 1} key={b.id} />
|
||||
// ))}
|
||||
// </>
|
||||
// );
|
||||
// }
|
||||
|
||||
|
||||
|
||||
// Code 11-4-7
|
||||
import { getAllReviews } from '@/lib/getter';
|
||||
import LinkedBookDetails from '@/components/LinkedBookDetails';
|
||||
|
||||
// 항상 최신 정보 얻기
|
||||
export const dynamic = 'force-dynamic';
|
||||
export default async function Home() {
|
||||
// 모든 리뷰 정보 얻기
|
||||
const reviews = await getAllReviews();
|
||||
console.log(reviews);
|
||||
return (
|
||||
<>
|
||||
{/* 획득한 리뷰 정보를 바탕으로 리스트 생성 */}
|
||||
{reviews.map((b, i) => (
|
||||
<LinkedBookDetails book={b} index={i + 1} key={b.id} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
23
modern-react/my-next/src/components/BookDetails.js
Normal file
23
modern-react/my-next/src/components/BookDetails.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import Image from 'next/image';
|
||||
|
||||
export default function BookDetails({ index, book }) {
|
||||
return (
|
||||
<div className="flex w-full mb-4">
|
||||
<div>
|
||||
{/* 책 그림자 표시 */}
|
||||
<Image src={book.image} alt="" width={140} height={180} />
|
||||
</div>
|
||||
<div>
|
||||
{/* 도서 정보 목록 표시 (index 속성이 지정되면 연속 번호도 표시) */}
|
||||
<ul className="list-none text-black ml-4">
|
||||
<li>{index && index + '.'}</li>
|
||||
<li>{book.title}({book.price}원)</li>
|
||||
<li>{book.author} 지음</li>
|
||||
<li>{book.publisher} 출판</li>
|
||||
<li>{book.published} 출시</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
72
modern-react/my-next/src/components/FormEdit.js
Normal file
72
modern-react/my-next/src/components/FormEdit.js
Normal file
@@ -0,0 +1,72 @@
|
||||
// 'use client';
|
||||
|
||||
// import { addReview, removeReview } from '@/lib/actions';
|
||||
|
||||
// export default function FormEdit({ src: { id, read, memo } }) {
|
||||
// return (
|
||||
// // 제출 시 addReview 메서드를 호출한다.
|
||||
// <form action={addReview}>
|
||||
// <input type="hidden" name="id" defaultValue={id} />
|
||||
// <div className="mb-3">
|
||||
// <label className="font-bold" htmlFor="read">읽은 날짜:</label>
|
||||
// <input type="date" id="read" name="read"
|
||||
// className="block bg-gray-100 border-2 border-gray-600 rounded focus:bg-white focus:outline-none focus:border-red-500"
|
||||
// defaultValue={read}/>
|
||||
// </div>
|
||||
// <div className="mb-3">
|
||||
// <label className="font-bold" htmlFor="memo">소감:</label>
|
||||
// <textarea id="memo" name="memo" rows="3"
|
||||
// className="block bg-gray-100 border-2 border-gray-600 w-full rounded focus:bg-white focus:outline-none focus:border-red-500"
|
||||
// defaultValue={memo}></textarea>
|
||||
// </div>
|
||||
// <button type="submit"
|
||||
// className="bg-blue-600 text-white rounded px-4 py-2 mr-2 hover:bg-blue-500">
|
||||
// 등록하기</button>
|
||||
// {/* [삭제하기] 버튼으로 removeReview 함수를 호출 */}
|
||||
// <button type="submit"
|
||||
// className="bg-red-600 text-white rounded px-4 py-2 hover:bg-red-500"
|
||||
// formAction={removeReview}>
|
||||
// 삭제하기</button>
|
||||
// </form>
|
||||
// );
|
||||
// }
|
||||
|
||||
|
||||
|
||||
// Code 11-4-16
|
||||
'use client';
|
||||
|
||||
import { useTransition } from 'react';
|
||||
import { addReview, removeReview } from '@/lib/actions';
|
||||
|
||||
export default function FormEdit({ src: { id, read, memo } }) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
// 이벤트 핸들러를 통해 서버 액션을 호출한다.
|
||||
return (
|
||||
<form action={addReview}>
|
||||
<input type="hidden" name="id" defaultValue={id} />
|
||||
<div className="mb-3">
|
||||
<label className="font-bold" htmlFor="read">읽은 날짜:</label>
|
||||
<input type="date" id="read" name="read"
|
||||
className="block bg-gray-100 border-2 border-gray-600 rounded focus:bg-white focus:outline-none focus:border-red-500"
|
||||
defaultValue={read}/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="font-bold" htmlFor="memo">소감:</label>
|
||||
<textarea id="memo" name="memo" rows="3"
|
||||
className="block bg-gray-100 border-2 border-gray-600 w-full rounded focus:bg-white focus:outline-none focus:border-red-500"
|
||||
defaultValue={memo}></textarea>
|
||||
</div>
|
||||
<button type="submit"
|
||||
className="bg-blue-600 text-white rounded px-4 py-2 mr-2 hover:bg-blue-500">
|
||||
등록하기</button>
|
||||
<button type="button"
|
||||
className="bg-red-600 text-white rounded px-4 py-2 hover:bg-red-500"
|
||||
onClick={() => {
|
||||
startTransition(() => removeReview(id));
|
||||
}}>
|
||||
삭제하기</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
13
modern-react/my-next/src/components/LinkedBookDetails.js
Normal file
13
modern-react/my-next/src/components/LinkedBookDetails.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import Link from 'next/link';
|
||||
import BookDetails from './BookDetails';
|
||||
|
||||
export default function LinkedBookDetails({ index, book }) {
|
||||
// BookDetails 컴포넌트에 링크 부여
|
||||
return (
|
||||
<Link href={`/edit/${book.id}`}>
|
||||
<div className="hover:bg-green-50">
|
||||
<BookDetails index={index} book={book} />
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
91
modern-react/my-next/src/lib/actions.js
Normal file
91
modern-react/my-next/src/lib/actions.js
Normal file
@@ -0,0 +1,91 @@
|
||||
// 'use server';
|
||||
|
||||
// import { redirect } from 'next/navigation';
|
||||
|
||||
// import prisma from './prisma';
|
||||
// import { getBookById } from './getter';
|
||||
|
||||
// // 폼에서 입력한 값을 데이터베이스에 등록
|
||||
// export async function addReview(data) {
|
||||
// const book = await getBookById(data.get('id'));
|
||||
// const input = {
|
||||
// title: book.title,
|
||||
// author: book.author,
|
||||
// price: Number(book.price),
|
||||
// publisher: book.publisher,
|
||||
// published: book.published,
|
||||
// image: book.image,
|
||||
// read: new Date(data.get('read')),
|
||||
// memo: data.get('memo')
|
||||
// };
|
||||
|
||||
// // 신규 데이터라면 등록, 기존 데이터라면 업데이트
|
||||
// await prisma.reviews.upsert({
|
||||
// update: input,
|
||||
// create: Object.assign({}, input, { id: data.get('id') }),
|
||||
// where: {
|
||||
// id: data.get('id')
|
||||
// }
|
||||
// });
|
||||
// // 처리 성공 후 홈페이지로 리디렉션
|
||||
// redirect('/');
|
||||
// }
|
||||
|
||||
// // 삭제 버튼으로 지정된 리뷰 정보 삭제
|
||||
// export async function removeReview(data) {
|
||||
// await prisma.reviews.delete({
|
||||
// where: {
|
||||
// id: data.get('id')
|
||||
// }
|
||||
// });
|
||||
// // 처리 성공 후 홈페이지로 리디렉션
|
||||
// redirect('/');
|
||||
// }
|
||||
|
||||
|
||||
|
||||
// Code 11-4-17
|
||||
'use server';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import prisma from './prisma';
|
||||
import { getBookById } from './getter';
|
||||
|
||||
// 폼에서 입력한 값을 데이터베이스에 등록
|
||||
export async function addReview(data) {
|
||||
const book = await getBookById(data.get('id'));
|
||||
const input = {
|
||||
title: book.title,
|
||||
author: book.author,
|
||||
price: Number(book.price),
|
||||
publisher: book.publisher,
|
||||
published: book.published,
|
||||
image: book.image,
|
||||
read: new Date(data.get('read')),
|
||||
memo: data.get('memo')
|
||||
};
|
||||
|
||||
// 신규 데이터라면 등록, 기존 데이터라면 업데이트
|
||||
await prisma.reviews.upsert({
|
||||
update: input,
|
||||
create: Object.assign({}, input, { id: data.get('id') }),
|
||||
where: {
|
||||
id: data.get('id')
|
||||
}
|
||||
});
|
||||
// 처리 성공 후 홈페이지로 리디렉션
|
||||
redirect('/');
|
||||
}
|
||||
|
||||
// 삭제 버튼으로 지정된 리뷰 정보 삭제
|
||||
export async function removeReview(data) {
|
||||
await prisma.reviews.delete({
|
||||
// 직접 id 값을 받기 때문에 수정
|
||||
where: {
|
||||
id: data
|
||||
}
|
||||
});
|
||||
// 처리 성공 후 홈페이지로 리디렉션
|
||||
redirect('/');
|
||||
}
|
||||
54
modern-react/my-next/src/lib/getter.js
Normal file
54
modern-react/my-next/src/lib/getter.js
Normal file
@@ -0,0 +1,54 @@
|
||||
import prisma from './prisma';
|
||||
|
||||
// API를 통해 얻은 도서 정보에서 필요한 정보만을 객체로 재구성
|
||||
export function createBook(book) {
|
||||
const authors = book.volumeInfo.authors;
|
||||
const price = book.saleInfo.listPrice;
|
||||
const img = book.volumeInfo.imageLinks;
|
||||
return {
|
||||
id: book.id,
|
||||
title: book.volumeInfo.title,
|
||||
author: authors ? authors.join(',') : '',
|
||||
price: price ? price.amount : 0,
|
||||
publisher: book.volumeInfo.publisher,
|
||||
published: book.volumeInfo.publishedDate,
|
||||
image: img ? img.smallThumbnail : '/vercel.svg',
|
||||
};
|
||||
}
|
||||
|
||||
// 인수 keyword를 키워드로 Google Books API에서 책 검색하기
|
||||
export async function getBooksByKeyword(keyword) {
|
||||
const res = await fetch(`https://www.googleapis.com/books/v1/volumes?q=${keyword}&langRestrict=ko&maxResults=20&printType=books`);
|
||||
const result = await res.json();
|
||||
const books = [];
|
||||
// 응답 내용을 객체 배열로 리필
|
||||
for (const b of result.items) {
|
||||
books.push(createBook(b));
|
||||
}
|
||||
return books;
|
||||
}
|
||||
|
||||
// id값을 키로 하여 도서 정보를 가져옴
|
||||
export async function getBookById(id) {
|
||||
const res = await fetch(`https://www.googleapis.com/books/v1/volumes/${id}`);
|
||||
const result = await res.json();
|
||||
return createBook(result);
|
||||
}
|
||||
|
||||
// id값을 키로 리뷰 정보 가져오기
|
||||
export async function getReviewById(id) {
|
||||
return await prisma.reviews.findUnique({
|
||||
where: {
|
||||
id: id
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAllReviews() {
|
||||
// 읽은 날짜(read) 내림차순으로 검색
|
||||
return await prisma.reviews.findMany({
|
||||
orderBy: {
|
||||
read: 'desc'
|
||||
}
|
||||
});
|
||||
}
|
||||
9
modern-react/my-next/src/lib/prisma.js
Normal file
9
modern-react/my-next/src/lib/prisma.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
// global.prisma에 Prisma 클라이언트가 존재할 경우 재사용
|
||||
const prisma = global.prisma ??
|
||||
new PrismaClient({ log: ['query'] });
|
||||
// Non-Production 환경에서는 global.prisma에 오브젝트를 저장한다.
|
||||
if (process.env.NODE_ENV !== 'production') global.prisma = prisma;
|
||||
|
||||
export default prisma;
|
||||
Reference in New Issue
Block a user