[modern-react] 리액트 스터디 파일 추가

This commit is contained in:
2025-09-30 23:55:13 +09:00
parent 31bcb2efe1
commit 75ec02d506
546 changed files with 141345 additions and 0 deletions

View 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}
</>
);
}

View File

@@ -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>
);
}

View 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} />
))}
</>
);
}

View 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>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View 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;
}
}

View 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>
);
}

View 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} />
))}
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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('/');
}

View 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'
}
});
}

View 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;