[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,36 @@
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.js`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.

View File

@@ -0,0 +1,7 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
}
}

View File

@@ -0,0 +1,18 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
serverActions: true,
},
images: {
remotePatterns: [
{
hostname: 'books.google.com'
},
{
hostname: 'wikibook.co.kr'
},
]
},
};
export default nextConfig;

5029
modern-react/my-next/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
{
"name": "my-next",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"vercel-build": "prisma generate && next build",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@prisma/client": "^5.12.1",
"@vercel/postgres": "^0.8.0",
"next": "14.2.2",
"react": "^18",
"react-dom": "^18"
},
"devDependencies": {
"eslint": "^8",
"eslint-config-next": "14.2.2",
"postcss": "^8",
"prisma": "^5.17.0",
"tailwindcss": "^3.4.1"
}
}

View File

@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;

Binary file not shown.

View File

@@ -0,0 +1,29 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
// datasource db {
// provider = "postgresql"
// url = env("POSTGRES_PRISMA_URL")
// directUrl = env("POSTGRES_URL_NON_POOLING")
// }
model reviews {
id String @id
title String
author String
price Int
publisher String
published String
image String
read DateTime @default(now())
memo String
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>

After

Width:  |  Height:  |  Size: 629 B

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;

View File

@@ -0,0 +1,20 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
// Tailwind CSS를 적용하는 파일군
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
// 공통 스타일 정의
extend: {
backgroundImage: {
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
"gradient-conic":
"conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
},
},
},
plugins: [],
};