initial commit

This commit is contained in:
USER
2026-10-21 09:56:01 +09:00
commit 4957998e3e
204 changed files with 42402 additions and 0 deletions
Binary file not shown.
@@ -0,0 +1,85 @@
# React 게시판 프로젝트 개발 가이드 (실무 실습용)
이 가이드는 `react_board` 프로젝트의 스켈레톤 코드를 완성해 나가는 과정에서 필요한 기술 스펙과 구현 팁을 담고 있습니다.
---
## 1. 프로젝트 환경
- **Framework**: React 19 (Vite 기반)
- **Language**: TypeScript
- **Styling**: Bootstrap (CDN 방식)
- **API Client**: Axios
- **Routing**: React Router DOM (v7)
---
## 2. API 명세서 (Backend Specification)
백엔드 서버 주소는 `.env` 파일의 `VITE_API_BASE_URL`을 사용합니다. (기본값: `http://localhost:8080/RestBoard`)
### 📋 게시글 목록 조회
- **Method**: `GET`
- **Path**: `/board/api/list`
- **Query Params**:
- `type`: 검색 타입 (`title`, `content`, `creaNm`)
- `keyword`: 검색어
- `page`: 페이지 번호 (1부터 시작)
- **Response Structure**:
```json
{
"list": [ { "idx": 1, "title": "제목", ... }, ... ],
"totalpage": 10
}
```
### 📋 게시글 상세 조회
- **Method**: `GET`
- **Path**: `/board/api/detail/{id}`
- **Response Structure**:
```json
{ "detail": { "idx": 1, "title": "제목", "contents": "내용", ... } }
```
### 📋 게시글 등록
- **Method**: `POST`
- **Path**: `/board/api/write`
- **Request Body**: `{ "title": "string", "creaNm": "string", "contents": "string" }`
---
## 3. 데이터 타입 (Data Types)
`src/types/board.ts`에 정의된 인터페이스를 적극 활용하세요.
- **IBoardItem**: 게시글 하나의 정보를 담는 객체.
- **IBoardResponse**: 목록 조회 시 서버로부터 받는 전체 데이터 구조.
- **IBoardDetailResponse**: 상세 조회 시의 데이터 구조.
---
## 4. 컴포넌트별 구현 가이드
### 📍 [BoardList.tsx](file:///c:/DEV/spring-workspace/RestBoard/src/main/front_end/react_board/src/components/BoardList.tsx)
- **핵심 로직**: `currentPage`와 `submittedSearchTerm`이 변경될 때마다 `retriveBoard()` 함수를 호출하도록 `useEffect`를 구성하세요.
- **팁**: 검색 버튼을 눌렀을 때만 검색이 실행되도록 하려면, 입력 중인 상태(`searchTerm`)와 실제 검색에 사용될 상태(`submittedSearchTerm`)를 분리하는 것이 좋습니다.
### 📍 [Pagination.tsx](file:///c:/DEV/spring-workspace/RestBoard/src/main/front_end/react_board/src/components/Pagination.tsx)
- **핵심 로직**: 한 그룹에 10페이지씩 보여준다면, 현재 페이지(`currentPage`)를 기준으로 그룹의 `startPage`를 계산하세요.
- **계산식**: `startPage = Math.floor((currentPage - 1) / 10) * 10 + 1`
- **팁**: `endPage`는 `startPage + 9`와 `totalPages` 중 더 작은 값을 택해야 에러가 나지 않습니다.
### 📍 [PostView.tsx](file:///c:/DEV/spring-workspace/RestBoard/src/main/front_end/react_board/src/components/PostView.tsx)
- **핵심 로직**: `useParams()`로 전달받은 `id` 값을 `useEffect`의 의존성 배열에 넣어 ID가 바뀔 때마다 데이터를 새로 불러옵니다.
- **UX**: 로딩 중일 때 사용자에게 피드백을 주기 위해 `loading` 상태 변수를 `boolean`으로 관리하세요.
### 📍 [PostForm.tsx](file:///c:/DEV/spring-workspace/RestBoard/src/main/front_end/react_board/src/components/PostForm.tsx)
- **핵심 로직**: React의 **제어 컴포넌트(Controlled Components)** 패턴을 사용하여 폼 데이터를 관리하세요.
- **주의사항**: `axios.post` 시 데이터 전송이 성공(`try`)하면 반드시 목록으로 이동(`useNavigate`)하게 하여 이중 등록되지 않도록 하세요.
---
## 5. 학습 포인트
> [!IMPORTANT]
> - **불변성(Immutability)**: React 상태를 업데이트할 때는 기존 배열이나 객체를 직접 수정하지 말고, 구조 분해 할당(`[...]`)이나 복사본을 만들어 새 값을 설정하세요.
> - **의존성 배열(Dependency Array)**: `useEffect`의 두 번째 인자인 배열을 잘못 설정하면 무한 루프에 빠지거나 데이터가 갱신되지 않을 수 있습니다. 항상 "어떤 값이 변할 때 이 함수를 불러야 하는가"를 고민하세요.
---
가이드 문서의 내용이 이해되지 않거나 더 구체적인 코드가 필요하시면 언제든 **AI 튜터**에게 물어보세요!
@@ -0,0 +1,55 @@
# 프론트엔드 실습용 백엔드 연동 힌트 및 첨부 파일 이슈 분석
이 문서는 `BoardRestController` 분석을 통해 도출된 프론트엔드 구현 힌트와, 현재 시스템 구조에서 첨부 파일 기능을 추가할 때 발생하는 기술적 병목 지점을 정리한 리포트입니다.
---
## 1. 프론트엔드 구현을 위한 API 매핑 힌트
프론트엔드 스켈레톤 코드의 `TODO:` 부분을 채울 때 다음 매핑 테이블을 참고하세요.
| 기능 | API 경로 (Endpoint) | HTTP 메서드 | 프론트엔드 전달 데이터 | 주의사항 |
| :--- | :--- | :--- | :--- | :--- |
| **목록 조회** | `/board/api/list` | `GET` | Query Params: `page`, `type`, `keyword` | `res.data.list`와 `res.data.totalpage` 바인딩 |
| **상세 조회** | `/board/api/detail/{idx}` | `GET` | Path Variable: `idx` | 조회수 자동 증가 로직 포함됨 |
| **게시물 등록** | `/board/api/detail` | **`POST`** | **JSON Body**: `{ title, creaNm, contents }` | **주의**: URL이 `/write`가 아닌 `/detail`임 |
| **게시물 수정** | `/board/api/detail/{idx}` | `POST` | JSON Body + Path Variable | 수정 성공 시 `code: "0"` 반환 |
| **게시물 삭제** | `/board/api/detail/{idx}` | `DELETE` | Path Variable: `idx` | Soft Delete (DEL_GB='Y') 방식 |
> [!TIP]
> 백엔드 응답에서 게시글 번호는 소문자 `idx`로 내려옵니다. (DB 컬럼명은 대문자이나 `BoardRestController`에서 소문자로 맵핑함)
---
## 2. 첨부 파일 기능 구현의 구조적 문제 분석
현재 프로젝트 구조에서 첨부 파일 기능을 구현하려고 하면 다음과 같은 **"기술적 장벽"**에 부딪히게 됩니다.
### 🚫 문제 1: Media Type 충돌 (JSON vs Multipart)
- **현상**: `BoardRestController`의 등록/수정 메서드는 `consumes = {MediaType.APPLICATION_JSON_VALUE}`로 선언되어 있습니다.
- **원인**: 이 설정은 **오직 JSON 데이터만** 받겠다는 뜻입니다. 파일 업로드에 필수적인 `multipart/form-data` 형식의 요청을 보내면 `415 Unsupported Media Type` 에러가 발생합니다.
- **해결**: `consumes` 설정을 제거하거나 `MULTIPART_FORM_DATA_VALUE`를 지원하도록 확장해야 합니다.
### 🚫 문제 2: 컨트롤러 파라미터 설계 미흡
- **현상**: `FileUtils.java`에서 파일을 추출하려면 `HttpServletRequest` 객체가 필요합니다.
- **원인**: 현재 `BoardRestController`의 메서드들은 `HttpServletRequest`를 인자로 받고 있지 않고, 오직 `@RequestBody Map`만 받고 있습니다. 파일 데이터가 담긴 요청 객체에 접근할 방법이 없습니다.
### 🚫 문제 3: 서비스 계층의 로직 부재 (Business Logic Gap)
- **현상**: 게시글 저장 기능인 `insertBoardDetail` 메서드 내부에 파일 저장 호출 코드가 없습니다.
- **원인**: 단순히 `boardDAO.insertBoardDetail`만 호출하고 끝납니다. 원래는 `FileUtils.parseInsertFileInfo`를 호출하여 리턴된 파일 리스트를 다시 `boardDAO.insertFile`과 같은 메서드로 전달하는 로직이 추가되어야 합니다.
### 🚫 문제 4: MyBatis 매퍼 및 DB 테이블 정의 부족
- **현상**: `Board_SQL.xml`에 게시글과 연관된 파일 정보를 저장하거나 조회하는 쿼리가 없습니다.
- **원인**: 파일 정보를 저장할 `TB_FILE`과 같은 테이블 정의와 이를 처리할 전용 SQL문이 누락되어 있습니다.
---
## 3. 해결 방안 제언 (Refactoring Path)
첨부 파일 기능을 학습 목적으로 구현하고 싶다면 다음 순서로 코드를 수정해 보세요.
1. **Backend (Controller)**: `consumes` 설정을 풀고, 인자에 `MultipartHttpServletRequest`를 추가합니다.
2. **Backend (Service)**: 게시물 저장 후 리턴된 `idx`를 사용하여 `fileUtils.parseInsertFileInfo`를 호출하고, 그 결과를 파일 전용 테이블에 저장하는 SQL을 호출합니다.
3. **Frontend**: `axios` 요청 시 데이터를 객체(`{}`)가 아닌 **`new FormData()`** 객체로 감싸서 보내야 합니다.
---
현재 구조는 **"기본 CRUD 실습"**에는 매우 최적화되어 있으나, **"파일 업로드"**는 별도의 심화 단계로 다루는 것이 적절해 보입니다.
@@ -0,0 +1,64 @@
# Vue 게시판 프로젝트 개발 가이드 (실무 실습용)
이 가이드는 `vue_board` 프로젝트의 스켈레톤 코드를 완성해 나가는 과정에서 필요한 기술 스펙과 Vue 고유의 구현 전략을 담고 있습니다.
---
## 1. 프로젝트 환경
- **Framework**: Vue 3 (Options API 방식)
- **Language**: TypeScript
- **Styling**: W3.CSS (CDN 방식)
- **API Client**: Axios (Global Plug-in 등록)
- **Routing**: Vue Router (v4)
---
## 2. API 명세서 (Backend Specification)
백엔드 주소는 전역 변수인 `this.$serverUrl`을 사용하여 접근합니다.
### 📋 API 엔드포인트 목록
- **목록 조회**: `GET /board/api/list` (params: type, keyword, page)
- **상세 조회**: `GET /board/api/detail/{idx}`
- **글 등록**: `POST /board/api/write` (body: JSON)
- **글 삭제**: `DELETE /board/api/delete/{idx}`
---
## 3. 전역 속성 활용 (this.$...)
`main.ts`에 등록된 다음 속성들을 사용하여 코드를 간결하게 작성하세요.
- **`this.$axios`**: Axios 별칭. 별도의 import 없이 `this.$axios.get(...)` 형태로 사용합니다.
- **`this.$serverUrl`**: 백엔드 API 서버의 기본 경로입니다.
- **`this.$router`**: 페이지 이동 제어 (예: `this.$router.push(...)`).
- **`this.$route`**: 현재 라우트 정보 접근 (예: `this.$route.params.idx`).
---
## 4. 컴포넌트별 구현 가이드
### 📍 [BoardList.vue](file:///c:/DEV/spring-workspace/RestBoard/src/main/front_end/vue_board/src/views/BoardList.vue)
- **핵심 로직**: `v-for` 디렉티브를 사용하여 `boardItems` 배열을 반복 렌더링하세요.
- **팁**: 검색 버튼 클릭 시 `currentPage`를 1로 초기화하고 데이터를 다시 불러오도록 `handleSearch` 메서드를 작성하세요.
### 📍 [Pagination.vue](file:///c:/DEV/spring-workspace/RestBoard/src/main/front_end/vue_board/src/components/Pagination.vue)
- **핵심 로직**: `props`로 받은 `currentPage`와 `totalPages`를 기반으로 페이지 번호를 생성하세요.
- **통신**: 페이지 번호 클릭 시 `this.$emit('page-change', pageNumber)`를 통해 부모(BoardList)에게 알리세요.
- **팁**: 반복되는 페이지 번호 배열은 `computed` 속성을 사용하여 가공하는 것이 성능상 유리합니다.
### 📍 [BoardDetail.vue](file:///c:/DEV/spring-workspace/RestBoard/src/main/front_end/vue_board/src/views/BoardDetail.vue)
- **핵심 로직**: `this.$route.params.idx`를 통해 URL의 ID 값을 가져와 `mounted()` 시점에 상세 조회를 실행합니다.
- **삭제**: `this.$axios.delete()`를 호출한 뒤 성공 시 목록으로 이동하도록 구현하세요.
### 📍 [BoardWrite.vue](file:///c:/DEV/spring-workspace/RestBoard/src/main/front_end/vue_board/src/views/BoardWrite.vue)
- **핵심 로직**: `v-model` 디렉티브를 사용하여 `input`과 `textarea`의 값을 `data()` 객체와 실시간으로 동기화하세요.
- **팁**: 작성 완료 후 `this.$router.push({ name: 'board' })`를 사용하여 목록으로 돌아갑니다.
---
## 5. 학습 포인트
> [!IMPORTANT]
> - **Reactivity**: `data()` 함수에서 반환하는 객체의 속성들은 Vue가 자동으로 변화를 감지하여 화면을 업데이트합니다.
> - **Directives**: `v-if`(조건부), `v-for`(반복), `v-on`(@ 기호로 대체), `v-bind`(: 기호로 대체) 등 전용 문법의 사용법에 익숙해지세요.
---
Vue의 선언적인 문법과 강력한 데이터 바인딩 기능을 활용해 멋진 게시판을 완성해 보세요!
+1
View File
@@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:8080/RestBoard
@@ -0,0 +1 @@
/node_modules/
+12
View File
@@ -0,0 +1,12 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
@@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,33 @@
{
"name": "react_board",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview",
"type-check": "tsc --noEmit"
},
"dependencies": {
"axios": "^1.12.2",
"bootstrap": "^5.3.8",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router-dom": "^7.9.1"
},
"devDependencies": {
"@eslint/js": "^9.35.0",
"@types/node": "^22.13.9",
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.2",
"eslint": "^9.35.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.4.0",
"typescript": "^5.7.3",
"vite": "^7.1.6"
}
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,27 @@
import React from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import Header from './components/Header';
import BoardList from './components/BoardList';
import PostView from './components/PostView';
import PostForm from './components/PostForm';
import PostEditForm from './components/PostEditForm';
const App: React.FC = () => {
return (
<Router>
<Header />
<div className="container mt-4">
<main>
<Routes>
<Route path="/" element={<BoardList />} />
<Route path="/post/:id" element={<PostView />} />
<Route path="/new" element={<PostForm />} />
<Route path="/edit/:id" element={<PostEditForm/>}/>
</Routes>
</main>
</div>
</Router>
);
}
export default App;
@@ -0,0 +1,31 @@
.pagination {
display: flex;
justify-content: center;
margin-top: 20px;
}
.pagination button {
margin: 0 5px;
padding: 8px 12px;
border: 1px solid #ddd;
background-color: #fff;
cursor: pointer;
border-radius: 4px;
font-size: 16px;
}
.pagination button:hover {
background-color: #f0f0f0;
}
.pagination button:disabled {
color: #ccc;
cursor: not-allowed;
}
.pagination button.active {
background-color: #007bff;
color: white;
border-color: #007bff;
font-weight: bold;
}
@@ -0,0 +1,26 @@
import axios from "axios";
import { IBoardDetailResponse, IBoardResponse } from "../types/board";
export const boardAPI = {
retriveBoard: async (type: String, keyword: String, page: Number) => {
const API_BASE_URL:String = import.meta.env.VITE_API_BASE_URL;
const URL = `${API_BASE_URL}/board/api/list`
const response = await axios.get<IBoardResponse>(URL, {
params: {
"type": type,
"keyword": keyword,
"page": page
}
}).then(response => {
console.log("response : ", response);
return response;
});
return response;
},
fetchPost: async(id: String)=>{
const API_BASE_URL:string = import.meta.env.VITE_API_BASE_URL;
const URL = `${API_BASE_URL}/board/api/detail/${id}`;
const response = await axios.get<IBoardDetailResponse>(URL);
return response;
}
};
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -0,0 +1,144 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import axios from 'axios';
import Pagination from './Pagination';
import { IBoardItem, IBoardResponse } from '../types/board';
import { boardAPI } from '../api/boards';
/**
* [실습 Phase 1 & 5] 게시판 목록 및 검색 컴포넌트
*
* 가이드:
* 1. useState를 사용하여 게시글 목록(posts), 검색어(searchTerm), 현재 페이지(currentPage), 전체 페이지(totalPages) 상태를 정의하세요.
* 2. useEffect를 사용하여 컴포넌트 마운트 시와 페이지/검색어 변경 시 데이터를 가져오는 로직을 구현하세요.
* 3. axios를 사용하여 `${import.meta.env.VITE_API_BASE_URL}/board/api/list`에 GET 요청을 보내세요.
* - 파라미터: type (검색타입), keyword (검색어), page (현재페이지)
* 4. 검색 버튼 클릭 및 엔터키 입력 시 검색이 실행되도록 핸들러를 작성하세요.
*/
const BoardList: React.FC = () => {
// TODO: 실습에 필요한 상태(State)를 정의하세요.
const [posts, setPosts] = useState<IBoardItem[]>([]);
const [searchTerm, setSearchTerm] = useState('');
const [submittedSearchTerm, setSubmittedSearchTerm] = useState('');
const [searchType, setSearchType] = useState('title');
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
// TODO: API 호출 함수(retriveBoard)를 작성하세요.
const retriveBoard = async () => {
const response = await boardAPI.retriveBoard(searchType, submittedSearchTerm, currentPage);
// 조회된 게시글 목록 상태 업데이트
setPosts(response.data.list);
// 전체 페이지 개수 업데이트
setTotalPages(response.data.totalpage);
};
const handleSearch = async () => {
const page = 1;
const response = await boardAPI.retriveBoard(searchType, submittedSearchTerm, page);
// 조회된 게시글 목록 상태 업데이트
setPosts(response.data.list);
// 전체 페이지 개수 업데이트
setTotalPages(response.data.totalpage);
// 현재 페이지를 첫번째 페이지로 업데이트
setCurrentPage(1)
console.log("response : ", response);
};
const handlePageChange = (page: number) => {
// 페이지 변경 로직 구현
setCurrentPage(page);
};
// TODO: useEffect를 사용하여 데이터 로드 시점을 제어하세요.
useEffect(() => {
retriveBoard();
}, [currentPage, submittedSearchTerm]);
return (
<>
<h2 className="mb-4">Board</h2>
{/* 검색 바 영역 */}
<div className="row justify-content-between mb-3">
<div className="col-md-auto">
<Link to="/new" className="btn btn-primary">New Post</Link>
</div>
<div className="col-md-7">
<div className="input-group">
<select
className="form-select"
style={{ flex: '0 0 120px' }}
value={searchType}
onChange={(e) => setSearchType(e.target.value)}
>
<option value="title">Title</option>
<option value="content">Content</option>
<option value="creaId">CreaId</option>
</select>
<input
type="text"
className="form-control"
placeholder="Search..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/* TODO: 엔터키 입력 시 검색 실행 (onKeyDown) */
onKeyDown={(e)=>{
const key = e.key;
if(key !== 'Enter'){
return;
}
setSubmittedSearchTerm(searchTerm);
}}
/>
<button className="btn btn-outline-secondary" type="button" onClick={handleSearch}>Search</button>
</div>
</div>
</div>
{/* 목록 테이블 영역 */}
<div className="table-responsive">
<table className="table table-hover">
<thead className="table-light">
<tr>
<th>No.</th>
<th>Title</th>
<th>CreaId</th>
<th>Date</th>
<th>Hits</th>
</tr>
</thead>
<tbody>
{/* TODO: posts 배열을 순회하며 목록을 출력하세요. (.map 활용) */}
{posts.length > 0 ? (
posts.map((post) => (
<tr key={post.idx}>
<td>{post.rnum}</td>
<td>
<Link to={`/post/${post.idx}`}>{post.title}</Link>
</td>
<td>{post.creaId}</td>
<td>{post.creaDtm}</td>
<td>{post.hitCnt}</td>
</tr>
))
) : (
<tr>
<td colSpan={5} className="text-center py-5">데이터가 없습니다. 로직을 구현해 보세요!</td>
</tr>
)}
</tbody>
</table>
</div>
{/* 페이징 컴포넌트 호출 */}
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
/>
</>
);
};
export default BoardList;
@@ -0,0 +1,29 @@
import React from 'react';
import { Link } from 'react-router-dom';
const Header: React.FC = () => {
return (
<header>
<nav className="navbar navbar-expand-lg navbar-dark bg-dark shadow-sm">
<div className="container">
<Link className="navbar-brand fw-bold" to="/">React Board</Link>
<button className="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span className="navbar-toggler-icon"></span>
</button>
<div className="collapse navbar-collapse" id="navbarNav">
<ul className="navbar-nav ms-auto">
<li className="nav-item">
<Link className="nav-link" to="/">Home</Link>
</li>
<li className="nav-item">
<Link className="nav-link" to="/new">New Post</Link>
</li>
</ul>
</div>
</div>
</nav>
</header>
);
};
export default Header;
@@ -0,0 +1,72 @@
import React from 'react';
interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
}
/**
* [실습 Phase 2] 공통 페이징 컴포넌트
*
* 가이드:
* 1. pagesPerGroup(예: 10)을 정의하여 한 번에 보여줄 페이지 갯수를 설정하세요.
* 2. startPage(그룹 시작 페이지)와 endPage(그룹 끝 페이지)를 현재 페이지를 기준으로 계산하세요.
* - 공식: Math.floor((currentPage - 1) / 10) * 10 + 1
* 3. 반복문(for)을 사용하여 startPage부터 endPage까지의 번호를 배열에 담으세요.
* 4. 이전 그룹, 다음 그룹, 개별 페이지 번호 클릭 시 onPageChange(번호)를 호출하도록 구현하세요.
*/
const Pagination: React.FC<PaginationProps> = ({ currentPage, totalPages, onPageChange }) => {
// TODO: 페이징 그룹 로직을 구현하세요.
const pagesPerGroup = 10;
const startPage = Math.floor((currentPage - 1) / pagesPerGroup) * pagesPerGroup + 1 // 계산 필요
const endPage = Math.min(startPage + 9, totalPages); // 계산 필요
const pageNumbers: number[] = [];
// TODO: for문을 사용하여 pageNumbers 배열을 구성하세요.
for(let i = startPage; i <= endPage; i++){
pageNumbers.push(i);
}
return (
<nav aria-label="Page navigation">
<ul className="pagination justify-content-center">
{/* TODO: 이전 그룹 링크 (&laquo;&laquo;) */}
<li className={`page-item ${startPage === 1 ? 'disabled' : ''}`}>
{/* 이전 페이지 그룹의 첫번재 페이지로 이동하기 */}
<button className="page-link" onClick={() => {onPageChange(startPage - pagesPerGroup)}}>&laquo;&laquo;</button>
</li>
{/* TODO: 이전 페이지 링크 (&laquo;) */}
<li className={`page-item ${currentPage === 1 ? 'disabled' : ''}`}>
<button className="page-link" onClick={() => {onPageChange(currentPage - 1)}}>&laquo;</button>
</li>
{/* TODO: pageNumbers를 순회하며 번호 버튼을 생성하세요. */}
{pageNumbers.map((number) => (
<li key={number} className={`page-item ${currentPage === number ? 'active' : ''}`}>
<button className="page-link" onClick={() => onPageChange(number)}>
{number}
</button>
</li>
))}
{/* TODO: 다음 페이지 및 다음 그룹 링크 (&raquo; 및 &raquo;&raquo;) */}
<li className={`page-item ${currentPage === totalPages ? 'disabled' : ''}`}>
<button className="page-link" onClick={() => {onPageChange(currentPage + 1)}}>&raquo;</button>
</li>
{/* 다음 페이지 그룹 이동 버튼 */}
<li className={`page-item ${endPage === totalPages ? 'disabled' : ''}`}>
{/* 다음 페이지 그룹의 첫번째 페이지 */}
<button className="page-link" onClick={() => {onPageChange(endPage + 1)}}>&raquo;&raquo;</button>
</li>
</ul>
<div className="text-center mt-2 small text-muted">
힌트: startPage = Math.floor((currentPage - 1) / 10) * 10 + 1
</div>
</nav>
);
};
export default Pagination;
@@ -0,0 +1,125 @@
import React, { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import axios from 'axios';
import { IBoardDetailResponse } from '../types/board';
import { boardAPI } from '../api/boards';
const PostEditForm: React.FC = () => {
// TODO: 폼 데이터를 위한 상태를 정의하세요 (useState)
const [title, setTitle] = useState('');
const [creaId, setCreaId] = useState('');
const [password, setPassword] = useState('');
const [contents, setContents] = useState('');
const [loading, setLoading] = useState(true);
const navigate = useNavigate();
const id = String(useParams().id); // useParams 활용
// TODO: 폼 전송 핸들러를 작성하세요.
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const API_BASE_URL:string = import.meta.env.VITE_API_BASE_URL;
const URL = `${API_BASE_URL}/board/api/detail/${id}`;
try{
await axios.post(URL, {
title: title,
password: password,
contents: contents
}).then(response=>{
if(response.data.code === "0"){
alert("게시글 수정에 성공했습니다.");
navigate(`/post/${id}`);
}else if(response.data.code === "-1"){
alert("게시글 수정에 실패했습니다.");
console.error(response.data.msg);
}
});
}catch(e){
alert("게시글 수정에 실패하였습니다.");
console.error(e);
}
};
const fetchPost = () => {
const response = boardAPI.fetchPost(id);
response.then(response=>{
const post = response.data.detail;
setTitle(post.title);
setCreaId(post.creaId);
setContents(post.contents);
}).finally(()=>setLoading(false));
};
useEffect(() => {
// fetchPost 호출 로직 구현
fetchPost();
}, [id]);
if (loading) return <div className="text-center mt-5">로딩 중...</div>;
return (
<>
<h2 className="mb-4">Edit Post</h2>
<div className="card shadow-sm">
<div className="card-body">
<form /* TODO: onSubmit 핸들러 연결 */
onSubmit={handleSubmit}
>
<div className="mb-3">
<label htmlFor="title" className="form-label fw-bold">Title</label>
<input
type="text"
className="form-control"
id="title"
required
value={title}
/* TODO: value와 onChange를 연결하세요 */
onChange={(e) => setTitle(e.target.value)}
/>
</div>
<div className="mb-3">
<label htmlFor="creaId" className="form-label fw-bold">CreaId</label>
<input
type="text"
className="form-control"
id="creaId"
value={creaId}
required
disabled
/>
</div>
<div className="mb-3">
<label htmlFor="password" className="form-label fw-bold">password</label>
<input
type="password"
className="form-control"
id="password"
required
/* TODO: value와 onChange를 연결하세요 */
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<div className="mb-3">
<label htmlFor="contents" className="form-label fw-bold">Content</label>
<textarea
className="form-control"
id="contents"
rows={5}
value={contents}
required
/* TODO: value와 onChange를 연결하세요 */
onChange={(e) => setContents(e.target.value)}
/>
</div>
<div className="d-flex justify-content-end gap-2">
<button type="button" className="btn btn-secondary" onClick={() => navigate(`/post/${id}`)}>Cancel</button>
<button type="submit" className="btn btn-primary px-4">Submit</button>
</div>
</form>
</div>
</div>
</>
);
};
export default PostEditForm;
@@ -0,0 +1,117 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import axios from 'axios';
/**
* [실습 Phase 4] 게시글 작성 컴포넌트
*
* 가이드:
* 1. useState를 사용하여 제목(title), 작성자(creaNm), 내용(contents) 상태를 각각 정의하세요.
* 2. Input 태그의 value를 상태와 연결하고, onChange 이벤트를 통해 상태가 업데이트되도록 구현하세요. (제어 컴포넌트)
* 3. 폼 제출 시(onSubmit) handleSubmit 함수를 실행하여 axios.post를 호출하세요.
* - 엔드포인트: `${import.meta.env.VITE_API_BASE_URL}/board/api/write`
* - 보낼 데이터: { title, creaNm, contents }
* 4. 등록 성공 후, navigate('/')를 사용하여 홈(목록) 화면으로 이동하세요.
*/
const PostForm: React.FC = () => {
// TODO: 폼 데이터를 위한 상태를 정의하세요 (useState)
const [title, setTitle] = useState('');
const [creaId, setCreaNm] = useState('');
const [password, setPassword] = useState('');
const [contents, setContents] = useState('');
const navigate = useNavigate();
// TODO: 폼 전송 핸들러를 작성하세요.
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
/*
try {
await axios.post(URL, { ... });
alert('성공!');
navigate('/');
} catch { ... }
*/
const API_BASE_URL:string = import.meta.env.VITE_API_BASE_URL;
const URL = `${API_BASE_URL}/board/api/detail`;
try{
await axios.post(URL, {
title: title,
creaId: creaId,
password: password,
contents: contents
});
alert("게시글 생성에 성공하였습니다.");
navigate("/");
}catch(e){
alert("게시글 생성에 실패하였습니다.");
console.error(e);
}
};
return (
<>
<h2 className="mb-4">New Post</h2>
<div className="card shadow-sm">
<div className="card-body">
<form /* TODO: onSubmit 핸들러 연결 */
onSubmit={handleSubmit}
>
<div className="mb-3">
<label htmlFor="title" className="form-label fw-bold">Title</label>
<input
type="text"
className="form-control"
id="title"
required
/* TODO: value와 onChange를 연결하세요 */
onChange={(e) => setTitle(e.target.value)}
/>
</div>
<div className="mb-3">
<label htmlFor="creaId" className="form-label fw-bold">CreaId</label>
<input
type="text"
className="form-control"
id="creaId"
required
/* TODO: value와 onChange를 연결하세요 */
onChange={(e) => setCreaNm(e.target.value)}
/>
</div>
<div className="mb-3">
<label htmlFor="password" className="form-label fw-bold">password</label>
<input
type="password"
className="form-control"
id="password"
required
/* TODO: value와 onChange를 연결하세요 */
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<div className="mb-3">
<label htmlFor="contents" className="form-label fw-bold">Content</label>
<textarea
className="form-control"
id="contents"
rows={5}
required
/* TODO: value와 onChange를 연결하세요 */
onChange={(e) => setContents(e.target.value)}
/>
</div>
<div className="d-flex justify-content-end gap-2">
<button type="button" className="btn btn-secondary" onClick={() => navigate('/')}>Cancel</button>
<button type="submit" className="btn btn-primary px-4">Submit</button>
</div>
</form>
</div>
</div>
<div className="mt-3 p-3 bg-light rounded text-center small">
힌트: React에서는 입력값이 바뀔 때마다 상태를 업데이트하는 <strong>제어 컴포넌트(Controlled Component)</strong> 방식을 권장합니다.
</div>
</>
);
};
export default PostForm;
@@ -0,0 +1,113 @@
import React, { useState, useEffect } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import axios from 'axios';
import { IBoardItem, IBoardDetailResponse, IBoardDeleteResponse, IBoardItem2 } from '../types/board';
import { boardAPI } from '../api/boards';
/**
* [실습 Phase 3] 게시글 상세 조회 컴포넌트
*
* 가이드:
* 1. useParams Hook을 사용하여 URL에서 id 파라미터를 추출하세요.
* 2. useState를 사용하여 post(상세 정보), loading(로딩 상태)를 관리하세요.
* 3. useEffect와 fetchPost(상세조회 API 호출) 함수를 구현하세요.
* - 엔드포인트: `${import.meta.env.VITE_API_BASE_URL}/board/api/detail/${id}`
* 4. 조건부 렌더링을 사용하여 데이터가 없는 경우나 로딩 중일 때 표시할 화면을 작성하세요.
* 5. 목록으로 돌아가기 버튼(useNavigate)과 수정/삭제 기능을 구상해 보세요.
*/
const PostView: React.FC = () => {
// TODO: 게시글 데이터와 로딩 상태 관리를 위해 useState를 활용하세요.
const [post, setPost] = useState<IBoardItem | null>(null);
const [loading, setLoading] = useState(true);
const navigate = useNavigate();
// TODO: URL 파라미터에서 ID를 받으세요. (useParams)
const id = String(useParams().id); // useParams 활용
// TODO: 상세 데이터를 가져오는 함수를 작성하세요.
const fetchPost = () => {
/*
axios.get<IBoardDetailResponse>(URL)
.then(response => { ... })
.finally(() => setLoading(false))
*/
const response = boardAPI.fetchPost(id);
response.then(response=>{
setPost(response.data.detail);
}).finally(()=>setLoading(false));
// const API_BASE_URL:string = import.meta.env.VITE_API_BASE_URL;
// const URL = `${API_BASE_URL}/board/api/detail/${id}`;
// axios.get<IBoardDetailResponse>(URL)
// .then(response=>{
// setPost(response.data.detail);
// })
// .finally(()=>setLoading(false));
};
// 게시글 삭제 이벤트
const handleDeletePost = (id: string)=>{
// axios 삭제 요청
const API_BASE_URL:string = import.meta.env.VITE_API_BASE_URL;
const URL = `${API_BASE_URL}/board/api/detail/${id}`;
if(!confirm("게시글을 삭제하시겠습니까?")){
return;
}
axios.delete<IBoardDeleteResponse>(URL)
.then(response=>{
// code=1(삭제 성공), code=-1(삭제 실패)
if(response.data.code === "1"){
alert("게시글 삭제 성공했습니다.");
navigate("/");
}else if(response.data.code === "-1"){
alert("게시글 삭제에 실패했습니다.");
}
})
.finally(()=>setLoading(false));
}
useEffect(() => {
// fetchPost 호출 로직 구현
fetchPost();
}, [id]);
// TODO: 렌더링 로직 (상태에 따라 화면 표시)
if (loading) return <div className="text-center mt-5">로딩 중...</div>;
if (!post) return <div className="alert alert-danger">게시글을 찾을 수 없습니다.</div>;
return (
<div className="card shadow-sm">
<div className="card-header bg-white py-3">
{/* TODO: 게시글 제목 출력 */}
<h2 className="mb-0">{post.title}</h2>
</div>
<div className="card-body">
<div className="mb-4 text-muted small">
{/* TODO: 작성자, 날짜, 조회수 정보 바인딩 */}
<span className="me-3"><strong>CreaId: </strong>{post.creaId}</span>
<span><strong>Date: </strong> {post.creaDtm}</span>
<span className="ms-3"><strong>Hits:</strong> {post.hitCnt}</span>
</div>
<hr />
{/* TODO: 본문 내용 출력 (contents) */}
<div className="my-4" style={{ minHeight: '200px', whiteSpace: 'pre-wrap' }}>
{post.contents}
</div>
</div>
<div className="card-footer bg-white py-3 d-flex justify-content-between">
<Link to="/" className="btn btn-secondary">Back to List</Link>
<div className="btn-group">
{/* TODO: 수정/삭제 기능을 추가해 보세요. */}
<button className="btn btn-outline-primary" onClick={() => navigate(`/edit/${id}`)}>Edit</button>
<button className="btn btn-outline-danger" onClick={() => handleDeletePost(id)}>Delete</button>
</div>
</div>
<div className="p-2 small text-muted text-center border-top">
힌트: useNavigate()를 사용하면 프로그래밍 방식으로 페이지를 이동할 수 있습니다.
</div>
</div>
);
};
export default PostView;
@@ -0,0 +1,65 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}
@@ -0,0 +1,11 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import 'bootstrap/dist/css/bootstrap.min.css'
import './index.css'
import App from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
@@ -0,0 +1,34 @@
export interface IBoardItem {
idx: number | string;
title: string;
contents: string;
creaId: string
creaNm: string;
creaDtm: string;
hitCnt: number | string;
rnum?: number;
}
export interface IBoardItem2{
idx: number | string;
title: string;
contents: string;
creaId: string;
creaDtm: string;
hitCnt: number | string;
rnum?: number;
}
export interface IBoardResponse {
list: IBoardItem[];
totalpage: number;
}
export interface IBoardDetailResponse {
detail: IBoardItem;
}
export interface IBoardDeleteResponse{
code: String,
msg: String
}
+7
View File
@@ -0,0 +1,7 @@
interface ImportMetaEnv{
readonly VITE_API_BASE_URL: string;
}
interface ImportMeta{
readonly env: ImportMetaEnv;
}
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
@@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.js", "src/**/*.d.ts"]
}
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})
+1
View File
@@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:8080/RestBoard
@@ -0,0 +1,12 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
@@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,33 @@
{
"name": "react_board",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview",
"type-check": "tsc --noEmit"
},
"dependencies": {
"axios": "^1.12.2",
"bootstrap": "^5.3.8",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router-dom": "^7.9.1"
},
"devDependencies": {
"@eslint/js": "^9.35.0",
"@types/node": "^22.13.9",
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.2",
"eslint": "^9.35.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.4.0",
"typescript": "^5.7.3",
"vite": "^7.1.6"
}
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,26 @@
import React from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import Header from './components/Header';
import BoardList from './components/BoardList';
import PostView from './components/PostView';
import PostForm from './components/PostForm';
import './App.css';
const App: React.FC = () => {
return (
<Router>
<Header />
<div className="container mt-4">
<main>
<Routes>
<Route path="/" element={<BoardList />} />
<Route path="/post/:id" element={<PostView />} />
<Route path="/new" element={<PostForm />} />
</Routes>
</main>
</div>
</Router>
);
}
export default App;
@@ -0,0 +1,31 @@
.pagination {
display: flex;
justify-content: center;
margin-top: 20px;
}
.pagination button {
margin: 0 5px;
padding: 8px 12px;
border: 1px solid #ddd;
background-color: #fff;
cursor: pointer;
border-radius: 4px;
font-size: 16px;
}
.pagination button:hover {
background-color: #f0f0f0;
}
.pagination button:disabled {
color: #ccc;
cursor: not-allowed;
}
.pagination button.active {
background-color: #007bff;
color: white;
border-color: #007bff;
font-weight: bold;
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -0,0 +1,118 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import axios from 'axios';
import Pagination from './Pagination';
import { IBoardItem, IBoardResponse } from '../types/board';
const BoardList: React.FC = () => {
const [posts, setPosts] = useState<IBoardItem[]>([]);
const [searchTerm, setSearchTerm] = useState('');
const [submittedSearchTerm, setSubmittedSearchTerm] = useState('');
const [searchType, setSearchType] = useState('title');
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const handleSearch = () => {
setSubmittedSearchTerm(searchTerm);
setCurrentPage(1);
};
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleSearch();
}
};
const retriveBoard = (type?: string, keyword?: string) => {
axios.get<IBoardResponse>(`${import.meta.env.VITE_API_BASE_URL}/board/api/list`, {
params: {
type: type,
keyword: keyword,
page: currentPage,
}
})
.then(response => {
setPosts(response.data.list);
setTotalPages(response.data.totalpage);
})
.catch(error => {
console.error('Error fetching board list:', error);
});
};
useEffect(() => {
retriveBoard(searchType, submittedSearchTerm);
}, [submittedSearchTerm, currentPage]);
const handlePageChange = (page: number) => setCurrentPage(page);
return (
<>
<h2 className="mb-4">Board</h2>
<div className="row justify-content-between mb-3">
<div className="col-md-auto">
<Link to="/new" className="btn btn-primary">New Post</Link>
</div>
<div className="col-md-7">
<div className="input-group">
<select
className="form-select"
style={{ flex: '0 0 120px' }}
value={searchType}
onChange={e => setSearchType(e.target.value)}
>
<option value="title">Title</option>
<option value="content">Content</option>
<option value="author">Author</option>
</select>
<input
type="text"
className="form-control"
placeholder={`Search by ${searchType}...`}
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
onKeyDown={handleKeyPress}
/>
<button className="btn btn-outline-secondary" type="button" onClick={handleSearch}>Search</button>
</div>
</div>
</div>
<div className="table-responsive">
<table className="table table-hover">
<thead className="table-light">
<tr>
<th>No.</th>
<th>Title</th>
<th>Author</th>
<th>Date</th>
<th>Hits</th>
</tr>
</thead>
<tbody>
{posts.map((post) => (
<tr key={post.idx}>
<td>{post.rnum}</td>
<td>
<Link to={`/post/${post.idx}`}>{post.title}</Link>
</td>
<td>{post.creaNm}</td>
<td>{post.creaDtm}</td>
<td>{post.hitCnt}</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
/>
</>
);
};
export default BoardList;
@@ -0,0 +1,29 @@
import React from 'react';
import { Link } from 'react-router-dom';
const Header: React.FC = () => {
return (
<header>
<nav className="navbar navbar-expand-lg navbar-dark bg-dark shadow-sm">
<div className="container">
<Link className="navbar-brand fw-bold" to="/">React Board</Link>
<button className="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span className="navbar-toggler-icon"></span>
</button>
<div className="collapse navbar-collapse" id="navbarNav">
<ul className="navbar-nav ms-auto">
<li className="nav-item">
<Link className="nav-link" to="/">Home</Link>
</li>
<li className="nav-item">
<Link className="nav-link" to="/new">New Post</Link>
</li>
</ul>
</div>
</div>
</nav>
</header>
);
};
export default Header;
@@ -0,0 +1,75 @@
import React from 'react';
interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
}
const Pagination: React.FC<PaginationProps> = ({ currentPage, totalPages, onPageChange }) => {
const pagesPerGroup = 10;
const startPage = Math.floor((currentPage - 1) / pagesPerGroup) * pagesPerGroup + 1;
const endPage = Math.min(startPage + pagesPerGroup - 1, totalPages);
const pageNumbers: number[] = [];
for (let i = startPage; i <= endPage; i++) {
pageNumbers.push(i);
}
return (
<nav aria-label="Page navigation">
<ul className="pagination justify-content-center">
<li className={`page-item ${startPage === 1 ? 'disabled' : ''}`}>
<button
className="page-link"
onClick={() => onPageChange(Math.max(startPage - pagesPerGroup, 1))}
aria-label="Previous Group"
>
<span aria-hidden="true">&laquo;&laquo;</span>
</button>
</li>
<li className={`page-item ${currentPage === 1 ? 'disabled' : ''}`}>
<button
className="page-link"
onClick={() => onPageChange(Math.max(currentPage - 1, 1))}
aria-label="Previous"
>
<span aria-hidden="true">&laquo;</span>
</button>
</li>
{pageNumbers.map((number) => (
<li key={number} className={`page-item ${currentPage === number ? 'active' : ''}`}>
<button
className="page-link"
onClick={() => onPageChange(number)}
>
{number}
</button>
</li>
))}
<li className={`page-item ${currentPage === totalPages ? 'disabled' : ''}`}>
<button
className="page-link"
onClick={() => onPageChange(Math.min(currentPage + 1, totalPages))}
aria-label="Next"
>
<span aria-hidden="true">&raquo;</span>
</button>
</li>
<li className={`page-item ${endPage === totalPages ? 'disabled' : ''}`}>
<button
className="page-link"
onClick={() => onPageChange(Math.min(startPage + pagesPerGroup, totalPages))}
aria-label="Next Group"
>
<span aria-hidden="true">&raquo;&raquo;</span>
</button>
</li>
</ul>
</nav>
);
};
export default Pagination;
@@ -0,0 +1,74 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import axios from 'axios';
const PostForm: React.FC = () => {
const [title, setTitle] = useState('');
const [creaNm, setCreaNm] = useState('');
const [contents, setContents] = useState('');
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
// Logic for API call
// await axios.post(`${import.meta.env.VITE_API_BASE_URL}/board/api/write`, { title, creaNm, contents });
console.log({ title, creaNm, contents });
alert('Post submitted successfully!');
navigate('/');
} catch (error) {
console.error('Error submitting post:', error);
}
};
return (
<>
<h2 className="mb-4">New Post</h2>
<div className="card shadow-sm">
<div className="card-body">
<form onSubmit={handleSubmit}>
<div className="mb-3">
<label htmlFor="title" className="form-label fw-bold">Title</label>
<input
type="text"
className="form-control"
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
/>
</div>
<div className="mb-3">
<label htmlFor="creaNm" className="form-label fw-bold">Author</label>
<input
type="text"
className="form-control"
id="creaNm"
value={creaNm}
onChange={(e) => setCreaNm(e.target.value)}
required
/>
</div>
<div className="mb-3">
<label htmlFor="contents" className="form-label fw-bold">Content</label>
<textarea
className="form-control"
id="contents"
rows={5}
value={contents}
onChange={(e) => setContents(e.target.value)}
required
/>
</div>
<div className="d-flex justify-content-end gap-2">
<button type="button" className="btn btn-secondary" onClick={() => navigate('/')}>Cancel</button>
<button type="submit" className="btn btn-primary px-4">Submit</button>
</div>
</form>
</div>
</div>
</>
);
};
export default PostForm;
@@ -0,0 +1,66 @@
import React, { useState, useEffect } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import axios from 'axios';
import { IBoardItem, IBoardDetailResponse } from '../types/board';
const PostView: React.FC = () => {
const { id } = useParams<{ id: string }>();
const [post, setPost] = useState<IBoardItem | null>(null);
const [loading, setLoading] = useState(true);
const navigate = useNavigate();
useEffect(() => {
const fetchPost = async () => {
try {
const response = await axios.get<IBoardDetailResponse>(`${import.meta.env.VITE_API_BASE_URL}/board/api/detail/${id}`);
setPost(response.data.detail);
} catch (error) {
console.error('Error fetching post detail:', error);
// Fallback for demo if API fails
setPost({
idx: id || '',
title: `Post ${id}`,
creaNm: `User${id}`,
contents: `This is the full content of post ${id}.`,
creaDtm: '2025-09-16',
hitCnt: 0
});
} finally {
setLoading(false);
}
};
fetchPost();
}, [id]);
if (loading) return <div className="text-center mt-5"><div className="spinner-border" role="status"></div></div>;
if (!post) return <div className="alert alert-danger">Post not found.</div>;
return (
<div className="card shadow-sm">
<div className="card-header bg-white py-3">
<h2 className="mb-0">{post.title}</h2>
</div>
<div className="card-body">
<div className="mb-4 text-muted small">
<span className="me-3"><strong>Author:</strong> {post.creaNm}</span>
<span><strong>Date:</strong> {post.creaDtm}</span>
<span className="ms-3"><strong>Hits:</strong> {post.hitCnt}</span>
</div>
<hr />
<div className="my-4" style={{ minHeight: '200px', whiteSpace: 'pre-wrap' }}>
{post.contents}
</div>
</div>
<div className="card-footer bg-white py-3 d-flex justify-content-between">
<Link to="/" className="btn btn-secondary">Back to List</Link>
<div className="btn-group">
<button className="btn btn-outline-primary" onClick={() => alert('Edit function coming soon')}>Edit</button>
<button className="btn btn-outline-danger" onClick={() => alert('Delete function coming soon')}>Delete</button>
</div>
</div>
</div>
);
};
export default PostView;
@@ -0,0 +1,65 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}
@@ -0,0 +1,11 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import 'bootstrap/dist/css/bootstrap.min.css'
import './index.css'
import App from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
@@ -0,0 +1,18 @@
export interface IBoardItem {
idx: number | string;
title: string;
contents: string;
creaNm: string;
creaDtm: string;
hitCnt: number | string;
rnum?: number;
}
export interface IBoardResponse {
list: IBoardItem[];
totalpage: number;
}
export interface IBoardDetailResponse {
detail: IBoardItem;
}
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
@@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.js"]
}
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})
+23
View File
@@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
+70
View File
@@ -0,0 +1,70 @@
# Getting Started with Create React App
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `npm start`
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
The page will reload when you make changes.\
You may also see any lint errors in the console.
### `npm test`
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).
### Code Splitting
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
### Analyzing the Bundle Size
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
### Making a Progressive Web App
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
### Advanced Configuration
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
### Deployment
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
### `npm run build` fails to minify
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,39 @@
{
"name": "tic-tac-toe",
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/dom": "^10.4.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^13.5.0",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"react-scripts": "5.0.1",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

@@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}
@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:
@@ -0,0 +1,38 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
+114
View File
@@ -0,0 +1,114 @@
import { useState } from "react";
function Square({value, onSquareClick}){
return (
<button className="square" onClick={onSquareClick}>{value}</button>
);
}
function Board({xIsNext, squares, onPlay}){
function handleClick(i){
if(calculateWinner(squares) || squares[i]){
return;
}
const nextSquares = squares.slice();
if(xIsNext){
nextSquares[i] = "X";
}else{
nextSquares[i] = "O";
}
onPlay(nextSquares);
}
const winner = calculateWinner(squares);
let status;
if(winner){
status = "Winner: " + winner;
}else{
status = "Next Player: " + (xIsNext ? "X" : "O");
}
return (
<>
<div className="status">{status}</div>
<div className="board-row">
<Square value={squares[0]} onSquareClick={()=>handleClick(0)}/>
<Square value={squares[1]} onSquareClick={()=>handleClick(1)}/>
<Square value={squares[2]} onSquareClick={()=>handleClick(2)}/>
</div>
<div className="board-row">
<Square value={squares[3]} onSquareClick={()=>handleClick(3)}/>
<Square value={squares[4]} onSquareClick={()=>handleClick(4)}/>
<Square value={squares[5]} onSquareClick={()=>handleClick(5)}/>
</div>
<div className="board-row">
<Square value={squares[6]} onSquareClick={()=>handleClick(6)}/>
<Square value={squares[7]} onSquareClick={()=>handleClick(7)}/>
<Square value={squares[8]} onSquareClick={()=>handleClick(8)}/>
</div>
</>
);
}
function calculateWinner(squares){
const lines = [
[0, 1, 2],
[3, 4, 5],
[6, 7, 8],
[0, 3, 6],
[1, 4, 7],
[2, 5, 8],
[0, 4, 8],
[2, 4, 6]
];
for(let i = 0; i < lines.length; i++){
const [a, b, c] = lines[i];
if(squares[a] && squares[a] === squares[b] && squares[a] === squares[c]){
return squares[a];
}
}
return null;
}
export default function Game(){
const [history, setHistory] = useState([Array(9).fill(null)]);
const [currentMove, setCurrentMove] = useState(0);
const xIsNext = currentMove % 2 === 0;
const currentSquares = history[currentMove];
function handlePlay(nextSquares){
const nextHistory = [...history.slice(0, currentMove + 1), nextSquares];
setHistory(nextHistory);
setCurrentMove(nextHistory.length - 1);
}
function jumpTo(nextMove){
setCurrentMove(nextMove);
}
const moves = history.map((squares, move)=>{
let description;
if(move > 0){
description = "Go to move #" + move;
}else{
description = "Go to game start";
}
return (
<li key={move}>
<button onClick={()=>jumpTo(move)}>{description}</button>
</li>
);
})
return (
<div className="game">
<div className="game-board">
<Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay}/>
</div>
<div className="game-info">
<ol>{moves}</ol>
</div>
</div>
);
}
@@ -0,0 +1,8 @@
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});
@@ -0,0 +1,13 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
@@ -0,0 +1,17 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

@@ -0,0 +1,13 @@
const reportWebVitals = onPerfEntry => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;
@@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';
@@ -0,0 +1,6 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
+1
View File
@@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:8080/RestBoard
+30
View File
@@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
@@ -0,0 +1,7 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}
+35
View File
@@ -0,0 +1,35 @@
# vue_board
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Compile and Minify for Production
```sh
npm run build
```
### Lint with [ESLint](https://eslint.org/)
```sh
npm run lint
```
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
@@ -0,0 +1,19 @@
import js from '@eslint/js'
import pluginVue from 'eslint-plugin-vue'
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
export default [
{
name: 'app/files-to-lint',
files: ['**/*.{js,mjs,jsx,vue}'],
},
{
name: 'app/files-to-ignore',
ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**'],
},
js.configs.recommended,
...pluginVue.configs['flat/essential'],
skipFormatting,
]
+17
View File
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<link rel="stylesheet" href="https://www.w3schools.com/w3css/4/w3.css">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
@@ -0,0 +1,8 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
{
"name": "vue_board",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint": "eslint . --fix",
"format": "prettier --write src/"
},
"dependencies": {
"axios": "^1.7.9",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@eslint/js": "^9.14.0",
"@tsconfig/node22": "^22.0.5",
"@types/node": "^22.13.9",
"@vitejs/plugin-vue": "^5.2.1",
"@vitejs/plugin-vue-jsx": "^4.1.1",
"@vue/eslint-config-prettier": "^10.1.0",
"@vue/tsconfig": "^0.7.0",
"eslint": "^9.14.0",
"eslint-plugin-vue": "^9.30.0",
"npm-run-all2": "^7.0.2",
"prettier": "^3.3.3",
"typescript": "~5.7.3",
"vite": "^6.0.5",
"vite-plugin-vue-devtools": "^7.6.8",
"vue-tsc": "^2.2.4"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+235
View File
@@ -0,0 +1,235 @@
/* W3.CSS 4.15 December 2020 by Jan Egil and Borge Refsnes */
html{box-sizing:border-box}*,*:before,*:after{box-sizing:inherit}
/* Extract from normalize.css by Nicolas Gallagher and Jonathan Neal git.io/normalize */
html{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}
article,aside,details,figcaption,figure,footer,header,main,menu,nav,section{display:block}summary{display:list-item}
audio,canvas,progress,video{display:inline-block}progress{vertical-align:baseline}
audio:not([controls]){display:none;height:0}[hidden],template{display:none}
a{background-color:transparent}a:active,a:hover{outline-width:0}
abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}
b,strong{font-weight:bolder}dfn{font-style:italic}mark{background:#ff0;color:#000}
small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}
sub{bottom:-0.25em}sup{top:-0.5em}figure{margin:1em 40px}img{border-style:none}
code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}hr{box-sizing:content-box;height:0;overflow:visible}
button,input,select,textarea,optgroup{font:inherit;margin:0}optgroup{font-weight:bold}
button,input{overflow:visible}button,select{text-transform:none}
button,[type=button],[type=reset],[type=submit]{-webkit-appearance:button}
button::-moz-focus-inner,[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner{border-style:none;padding:0}
button:-moz-focusring,[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring{outline:1px dotted ButtonText}
fieldset{border:1px solid #c0c0c0;margin:0 2px;padding:.35em .625em .75em}
legend{color:inherit;display:table;max-width:100%;padding:0;white-space:normal}textarea{overflow:auto}
[type=checkbox],[type=radio]{padding:0}
[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}
[type=search]{-webkit-appearance:textfield;outline-offset:-2px}
[type=search]::-webkit-search-decoration{-webkit-appearance:none}
::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}
/* End extract */
html,body{font-family:Verdana,sans-serif;font-size:15px;line-height:1.5}html{overflow-x:hidden}
h1{font-size:36px}h2{font-size:30px}h3{font-size:24px}h4{font-size:20px}h5{font-size:18px}h6{font-size:16px}
.w3-serif{font-family:serif}.w3-sans-serif{font-family:sans-serif}.w3-cursive{font-family:cursive}.w3-monospace{font-family:monospace}
h1,h2,h3,h4,h5,h6{font-family:"Segoe UI",Arial,sans-serif;font-weight:400;margin:10px 0}.w3-wide{letter-spacing:4px}
hr{border:0;border-top:1px solid #eee;margin:20px 0}
.w3-image{max-width:100%;height:auto}img{vertical-align:middle}a{color:inherit}
.w3-table,.w3-table-all{border-collapse:collapse;border-spacing:0;width:100%;display:table}.w3-table-all{border:1px solid #ccc}
.w3-bordered tr,.w3-table-all tr{border-bottom:1px solid #ddd}.w3-striped tbody tr:nth-child(even){background-color:#f1f1f1}
.w3-table-all tr:nth-child(odd){background-color:#fff}.w3-table-all tr:nth-child(even){background-color:#f1f1f1}
.w3-hoverable tbody tr:hover,.w3-ul.w3-hoverable li:hover{background-color:#ccc}.w3-centered tr th,.w3-centered tr td{text-align:center}
.w3-table td,.w3-table th,.w3-table-all td,.w3-table-all th{padding:8px 8px;display:table-cell;text-align:left;vertical-align:top}
.w3-table th:first-child,.w3-table td:first-child,.w3-table-all th:first-child,.w3-table-all td:first-child{padding-left:16px}
.w3-btn,.w3-button{border:none;display:inline-block;padding:8px 16px;vertical-align:middle;overflow:hidden;text-decoration:none;color:inherit;background-color:inherit;text-align:center;cursor:pointer;white-space:nowrap}
.w3-btn:hover{box-shadow:0 8px 16px 0 rgba(0,0,0,0.2),0 6px 20px 0 rgba(0,0,0,0.19)}
.w3-btn,.w3-button{-webkit-touch-callout:none;-webkit-user-select:none;-khtml-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}
.w3-disabled,.w3-btn:disabled,.w3-button:disabled{cursor:not-allowed;opacity:0.3}.w3-disabled *,:disabled *{pointer-events:none}
.w3-btn.w3-disabled:hover,.w3-btn:disabled:hover{box-shadow:none}
.w3-badge,.w3-tag{background-color:#000;color:#fff;display:inline-block;padding-left:8px;padding-right:8px;text-align:center}.w3-badge{border-radius:50%}
.w3-ul{list-style-type:none;padding:0;margin:0}.w3-ul li{padding:8px 16px;border-bottom:1px solid #ddd}.w3-ul li:last-child{border-bottom:none}
.w3-tooltip,.w3-display-container{position:relative}.w3-tooltip .w3-text{display:none}.w3-tooltip:hover .w3-text{display:inline-block}
.w3-ripple:active{opacity:0.5}.w3-ripple{transition:opacity 0s}
.w3-input{padding:8px;display:block;border:none;border-bottom:1px solid #ccc;width:100%}
.w3-select{padding:9px 0;width:100%;border:none;border-bottom:1px solid #ccc}
.w3-dropdown-click,.w3-dropdown-hover{position:relative;display:inline-block;cursor:pointer}
.w3-dropdown-hover:hover .w3-dropdown-content{display:block}
.w3-dropdown-hover:first-child,.w3-dropdown-click:hover{background-color:#ccc;color:#000}
.w3-dropdown-hover:hover > .w3-button:first-child,.w3-dropdown-click:hover > .w3-button:first-child{background-color:#ccc;color:#000}
.w3-dropdown-content{cursor:auto;color:#000;background-color:#fff;display:none;position:absolute;min-width:160px;margin:0;padding:0;z-index:1}
.w3-check,.w3-radio{width:24px;height:24px;position:relative;top:6px}
.w3-sidebar{height:100%;width:200px;background-color:#fff;position:fixed!important;z-index:1;overflow:auto}
.w3-bar-block .w3-dropdown-hover,.w3-bar-block .w3-dropdown-click{width:100%}
.w3-bar-block .w3-dropdown-hover .w3-dropdown-content,.w3-bar-block .w3-dropdown-click .w3-dropdown-content{min-width:100%}
.w3-bar-block .w3-dropdown-hover .w3-button,.w3-bar-block .w3-dropdown-click .w3-button{width:100%;text-align:left;padding:8px 16px}
.w3-main,#main{transition:margin-left .4s}
.w3-modal{z-index:3;display:none;padding-top:100px;position:fixed;left:0;top:0;width:100%;height:100%;overflow:auto;background-color:rgb(0,0,0);background-color:rgba(0,0,0,0.4)}
.w3-modal-content{margin:auto;background-color:#fff;position:relative;padding:0;outline:0;width:600px}
.w3-bar{width:100%;overflow:hidden}.w3-center .w3-bar{display:inline-block;width:auto}
.w3-bar .w3-bar-item{padding:8px 16px;float:left;width:auto;border:none;display:block;outline:0}
.w3-bar .w3-dropdown-hover,.w3-bar .w3-dropdown-click{position:static;float:left}
.w3-bar .w3-button{white-space:normal}
.w3-bar-block .w3-bar-item{width:100%;display:block;padding:8px 16px;text-align:left;border:none;white-space:normal;float:none;outline:0}
.w3-bar-block.w3-center .w3-bar-item{text-align:center}.w3-block{display:block;width:100%}
.w3-responsive{display:block;overflow-x:auto}
.w3-container:after,.w3-container:before,.w3-panel:after,.w3-panel:before,.w3-row:after,.w3-row:before,.w3-row-padding:after,.w3-row-padding:before,
.w3-cell-row:before,.w3-cell-row:after,.w3-clear:after,.w3-clear:before,.w3-bar:before,.w3-bar:after{content:"";display:table;clear:both}
.w3-col,.w3-half,.w3-third,.w3-twothird,.w3-threequarter,.w3-quarter{float:left;width:100%}
.w3-col.s1{width:8.33333%}.w3-col.s2{width:16.66666%}.w3-col.s3{width:24.99999%}.w3-col.s4{width:33.33333%}
.w3-col.s5{width:41.66666%}.w3-col.s6{width:49.99999%}.w3-col.s7{width:58.33333%}.w3-col.s8{width:66.66666%}
.w3-col.s9{width:74.99999%}.w3-col.s10{width:83.33333%}.w3-col.s11{width:91.66666%}.w3-col.s12{width:99.99999%}
@media (min-width:601px){.w3-col.m1{width:8.33333%}.w3-col.m2{width:16.66666%}.w3-col.m3,.w3-quarter{width:24.99999%}.w3-col.m4,.w3-third{width:33.33333%}
.w3-col.m5{width:41.66666%}.w3-col.m6,.w3-half{width:49.99999%}.w3-col.m7{width:58.33333%}.w3-col.m8,.w3-twothird{width:66.66666%}
.w3-col.m9,.w3-threequarter{width:74.99999%}.w3-col.m10{width:83.33333%}.w3-col.m11{width:91.66666%}.w3-col.m12{width:99.99999%}}
@media (min-width:993px){.w3-col.l1{width:8.33333%}.w3-col.l2{width:16.66666%}.w3-col.l3{width:24.99999%}.w3-col.l4{width:33.33333%}
.w3-col.l5{width:41.66666%}.w3-col.l6{width:49.99999%}.w3-col.l7{width:58.33333%}.w3-col.l8{width:66.66666%}
.w3-col.l9{width:74.99999%}.w3-col.l10{width:83.33333%}.w3-col.l11{width:91.66666%}.w3-col.l12{width:99.99999%}}
.w3-rest{overflow:hidden}.w3-stretch{margin-left:-16px;margin-right:-16px}
.w3-content,.w3-auto{margin-left:auto;margin-right:auto}.w3-content{max-width:980px}.w3-auto{max-width:1140px}
.w3-cell-row{display:table;width:100%}.w3-cell{display:table-cell}
.w3-cell-top{vertical-align:top}.w3-cell-middle{vertical-align:middle}.w3-cell-bottom{vertical-align:bottom}
.w3-hide{display:none!important}.w3-show-block,.w3-show{display:block!important}.w3-show-inline-block{display:inline-block!important}
@media (max-width:1205px){.w3-auto{max-width:95%}}
@media (max-width:600px){.w3-modal-content{margin:0 10px;width:auto!important}.w3-modal{padding-top:30px}
.w3-dropdown-hover.w3-mobile .w3-dropdown-content,.w3-dropdown-click.w3-mobile .w3-dropdown-content{position:relative}
.w3-hide-small{display:none!important}.w3-mobile{display:block;width:100%!important}.w3-bar-item.w3-mobile,.w3-dropdown-hover.w3-mobile,.w3-dropdown-click.w3-mobile{text-align:center}
.w3-dropdown-hover.w3-mobile,.w3-dropdown-hover.w3-mobile .w3-btn,.w3-dropdown-hover.w3-mobile .w3-button,.w3-dropdown-click.w3-mobile,.w3-dropdown-click.w3-mobile .w3-btn,.w3-dropdown-click.w3-mobile .w3-button{width:100%}}
@media (max-width:768px){.w3-modal-content{width:500px}.w3-modal{padding-top:50px}}
@media (min-width:993px){.w3-modal-content{width:900px}.w3-hide-large{display:none!important}.w3-sidebar.w3-collapse{display:block!important}}
@media (max-width:992px) and (min-width:601px){.w3-hide-medium{display:none!important}}
@media (max-width:992px){.w3-sidebar.w3-collapse{display:none}.w3-main{margin-left:0!important;margin-right:0!important}.w3-auto{max-width:100%}}
.w3-top,.w3-bottom{position:fixed;width:100%;z-index:1}.w3-top{top:0}.w3-bottom{bottom:0}
.w3-overlay{position:fixed;display:none;width:100%;height:100%;top:0;left:0;right:0;bottom:0;background-color:rgba(0,0,0,0.5);z-index:2}
.w3-display-topleft{position:absolute;left:0;top:0}.w3-display-topright{position:absolute;right:0;top:0}
.w3-display-bottomleft{position:absolute;left:0;bottom:0}.w3-display-bottomright{position:absolute;right:0;bottom:0}
.w3-display-middle{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);-ms-transform:translate(-50%,-50%)}
.w3-display-left{position:absolute;top:50%;left:0%;transform:translate(0%,-50%);-ms-transform:translate(-0%,-50%)}
.w3-display-right{position:absolute;top:50%;right:0%;transform:translate(0%,-50%);-ms-transform:translate(0%,-50%)}
.w3-display-topmiddle{position:absolute;left:50%;top:0;transform:translate(-50%,0%);-ms-transform:translate(-50%,0%)}
.w3-display-bottommiddle{position:absolute;left:50%;bottom:0;transform:translate(-50%,0%);-ms-transform:translate(-50%,0%)}
.w3-display-container:hover .w3-display-hover{display:block}.w3-display-container:hover span.w3-display-hover{display:inline-block}.w3-display-hover{display:none}
.w3-display-position{position:absolute}
.w3-circle{border-radius:50%}
.w3-round-small{border-radius:2px}.w3-round,.w3-round-medium{border-radius:4px}.w3-round-large{border-radius:8px}.w3-round-xlarge{border-radius:16px}.w3-round-xxlarge{border-radius:32px}
.w3-row-padding,.w3-row-padding>.w3-half,.w3-row-padding>.w3-third,.w3-row-padding>.w3-twothird,.w3-row-padding>.w3-threequarter,.w3-row-padding>.w3-quarter,.w3-row-padding>.w3-col{padding:0 8px}
.w3-container,.w3-panel{padding:0.01em 16px}.w3-panel{margin-top:16px;margin-bottom:16px}
.w3-code,.w3-codespan{font-family:Consolas,"courier new";font-size:16px}
.w3-code{width:auto;background-color:#fff;padding:8px 12px;border-left:4px solid #4CAF50;word-wrap:break-word}
.w3-codespan{color:crimson;background-color:#f1f1f1;padding-left:4px;padding-right:4px;font-size:110%}
.w3-card,.w3-card-2{box-shadow:0 2px 5px 0 rgba(0,0,0,0.16),0 2px 10px 0 rgba(0,0,0,0.12)}
.w3-card-4,.w3-hover-shadow:hover{box-shadow:0 4px 10px 0 rgba(0,0,0,0.2),0 4px 20px 0 rgba(0,0,0,0.19)}
.w3-spin{animation:w3-spin 2s infinite linear}@keyframes w3-spin{0%{transform:rotate(0deg)}100%{transform:rotate(359deg)}}
.w3-animate-fading{animation:fading 10s infinite}@keyframes fading{0%{opacity:0}50%{opacity:1}100%{opacity:0}}
.w3-animate-opacity{animation:opac 0.8s}@keyframes opac{from{opacity:0} to{opacity:1}}
.w3-animate-top{position:relative;animation:animatetop 0.4s}@keyframes animatetop{from{top:-300px;opacity:0} to{top:0;opacity:1}}
.w3-animate-left{position:relative;animation:animateleft 0.4s}@keyframes animateleft{from{left:-300px;opacity:0} to{left:0;opacity:1}}
.w3-animate-right{position:relative;animation:animateright 0.4s}@keyframes animateright{from{right:-300px;opacity:0} to{right:0;opacity:1}}
.w3-animate-bottom{position:relative;animation:animatebottom 0.4s}@keyframes animatebottom{from{bottom:-300px;opacity:0} to{bottom:0;opacity:1}}
.w3-animate-zoom {animation:animatezoom 0.6s}@keyframes animatezoom{from{transform:scale(0)} to{transform:scale(1)}}
.w3-animate-input{transition:width 0.4s ease-in-out}.w3-animate-input:focus{width:100%!important}
.w3-opacity,.w3-hover-opacity:hover{opacity:0.60}.w3-opacity-off,.w3-hover-opacity-off:hover{opacity:1}
.w3-opacity-max{opacity:0.25}.w3-opacity-min{opacity:0.75}
.w3-greyscale-max,.w3-grayscale-max,.w3-hover-greyscale:hover,.w3-hover-grayscale:hover{filter:grayscale(100%)}
.w3-greyscale,.w3-grayscale{filter:grayscale(75%)}.w3-greyscale-min,.w3-grayscale-min{filter:grayscale(50%)}
.w3-sepia{filter:sepia(75%)}.w3-sepia-max,.w3-hover-sepia:hover{filter:sepia(100%)}.w3-sepia-min{filter:sepia(50%)}
.w3-tiny{font-size:10px!important}.w3-small{font-size:12px!important}.w3-medium{font-size:15px!important}.w3-large{font-size:18px!important}
.w3-xlarge{font-size:24px!important}.w3-xxlarge{font-size:36px!important}.w3-xxxlarge{font-size:48px!important}.w3-jumbo{font-size:64px!important}
.w3-left-align{text-align:left!important}.w3-right-align{text-align:right!important}.w3-justify{text-align:justify!important}.w3-center{text-align:center!important}
.w3-border-0{border:0!important}.w3-border{border:1px solid #ccc!important}
.w3-border-top{border-top:1px solid #ccc!important}.w3-border-bottom{border-bottom:1px solid #ccc!important}
.w3-border-left{border-left:1px solid #ccc!important}.w3-border-right{border-right:1px solid #ccc!important}
.w3-topbar{border-top:6px solid #ccc!important}.w3-bottombar{border-bottom:6px solid #ccc!important}
.w3-leftbar{border-left:6px solid #ccc!important}.w3-rightbar{border-right:6px solid #ccc!important}
.w3-section,.w3-code{margin-top:16px!important;margin-bottom:16px!important}
.w3-margin{margin:16px!important}.w3-margin-top{margin-top:16px!important}.w3-margin-bottom{margin-bottom:16px!important}
.w3-margin-left{margin-left:16px!important}.w3-margin-right{margin-right:16px!important}
.w3-padding-small{padding:4px 8px!important}.w3-padding{padding:8px 16px!important}.w3-padding-large{padding:12px 24px!important}
.w3-padding-16{padding-top:16px!important;padding-bottom:16px!important}.w3-padding-24{padding-top:24px!important;padding-bottom:24px!important}
.w3-padding-32{padding-top:32px!important;padding-bottom:32px!important}.w3-padding-48{padding-top:48px!important;padding-bottom:48px!important}
.w3-padding-64{padding-top:64px!important;padding-bottom:64px!important}
.w3-padding-top-64{padding-top:64px!important}.w3-padding-top-48{padding-top:48px!important}
.w3-padding-top-32{padding-top:32px!important}.w3-padding-top-24{padding-top:24px!important}
.w3-left{float:left!important}.w3-right{float:right!important}
.w3-button:hover{color:#000!important;background-color:#ccc!important}
.w3-transparent,.w3-hover-none:hover{background-color:transparent!important}
.w3-hover-none:hover{box-shadow:none!important}
/* Colors */
.w3-amber,.w3-hover-amber:hover{color:#000!important;background-color:#ffc107!important}
.w3-aqua,.w3-hover-aqua:hover{color:#000!important;background-color:#00ffff!important}
.w3-blue,.w3-hover-blue:hover{color:#fff!important;background-color:#2196F3!important}
.w3-light-blue,.w3-hover-light-blue:hover{color:#000!important;background-color:#87CEEB!important}
.w3-brown,.w3-hover-brown:hover{color:#fff!important;background-color:#795548!important}
.w3-cyan,.w3-hover-cyan:hover{color:#000!important;background-color:#00bcd4!important}
.w3-blue-grey,.w3-hover-blue-grey:hover,.w3-blue-gray,.w3-hover-blue-gray:hover{color:#fff!important;background-color:#607d8b!important}
.w3-green,.w3-hover-green:hover{color:#fff!important;background-color:#4CAF50!important}
.w3-light-green,.w3-hover-light-green:hover{color:#000!important;background-color:#8bc34a!important}
.w3-indigo,.w3-hover-indigo:hover{color:#fff!important;background-color:#3f51b5!important}
.w3-khaki,.w3-hover-khaki:hover{color:#000!important;background-color:#f0e68c!important}
.w3-lime,.w3-hover-lime:hover{color:#000!important;background-color:#cddc39!important}
.w3-orange,.w3-hover-orange:hover{color:#000!important;background-color:#ff9800!important}
.w3-deep-orange,.w3-hover-deep-orange:hover{color:#fff!important;background-color:#ff5722!important}
.w3-pink,.w3-hover-pink:hover{color:#fff!important;background-color:#e91e63!important}
.w3-purple,.w3-hover-purple:hover{color:#fff!important;background-color:#9c27b0!important}
.w3-deep-purple,.w3-hover-deep-purple:hover{color:#fff!important;background-color:#673ab7!important}
.w3-red,.w3-hover-red:hover{color:#fff!important;background-color:#f44336!important}
.w3-sand,.w3-hover-sand:hover{color:#000!important;background-color:#fdf5e6!important}
.w3-teal,.w3-hover-teal:hover{color:#fff!important;background-color:#009688!important}
.w3-yellow,.w3-hover-yellow:hover{color:#000!important;background-color:#ffeb3b!important}
.w3-white,.w3-hover-white:hover{color:#000!important;background-color:#fff!important}
.w3-black,.w3-hover-black:hover{color:#fff!important;background-color:#000!important}
.w3-grey,.w3-hover-grey:hover,.w3-gray,.w3-hover-gray:hover{color:#000!important;background-color:#9e9e9e!important}
.w3-light-grey,.w3-hover-light-grey:hover,.w3-light-gray,.w3-hover-light-gray:hover{color:#000!important;background-color:#f1f1f1!important}
.w3-dark-grey,.w3-hover-dark-grey:hover,.w3-dark-gray,.w3-hover-dark-gray:hover{color:#fff!important;background-color:#616161!important}
.w3-pale-red,.w3-hover-pale-red:hover{color:#000!important;background-color:#ffdddd!important}
.w3-pale-green,.w3-hover-pale-green:hover{color:#000!important;background-color:#ddffdd!important}
.w3-pale-yellow,.w3-hover-pale-yellow:hover{color:#000!important;background-color:#ffffcc!important}
.w3-pale-blue,.w3-hover-pale-blue:hover{color:#000!important;background-color:#ddffff!important}
.w3-text-amber,.w3-hover-text-amber:hover{color:#ffc107!important}
.w3-text-aqua,.w3-hover-text-aqua:hover{color:#00ffff!important}
.w3-text-blue,.w3-hover-text-blue:hover{color:#2196F3!important}
.w3-text-light-blue,.w3-hover-text-light-blue:hover{color:#87CEEB!important}
.w3-text-brown,.w3-hover-text-brown:hover{color:#795548!important}
.w3-text-cyan,.w3-hover-text-cyan:hover{color:#00bcd4!important}
.w3-text-blue-grey,.w3-hover-text-blue-grey:hover,.w3-text-blue-gray,.w3-hover-text-blue-gray:hover{color:#607d8b!important}
.w3-text-green,.w3-hover-text-green:hover{color:#4CAF50!important}
.w3-text-light-green,.w3-hover-text-light-green:hover{color:#8bc34a!important}
.w3-text-indigo,.w3-hover-text-indigo:hover{color:#3f51b5!important}
.w3-text-khaki,.w3-hover-text-khaki:hover{color:#b4aa50!important}
.w3-text-lime,.w3-hover-text-lime:hover{color:#cddc39!important}
.w3-text-orange,.w3-hover-text-orange:hover{color:#ff9800!important}
.w3-text-deep-orange,.w3-hover-text-deep-orange:hover{color:#ff5722!important}
.w3-text-pink,.w3-hover-text-pink:hover{color:#e91e63!important}
.w3-text-purple,.w3-hover-text-purple:hover{color:#9c27b0!important}
.w3-text-deep-purple,.w3-hover-text-deep-purple:hover{color:#673ab7!important}
.w3-text-red,.w3-hover-text-red:hover{color:#f44336!important}
.w3-text-sand,.w3-hover-text-sand:hover{color:#fdf5e6!important}
.w3-text-teal,.w3-hover-text-teal:hover{color:#009688!important}
.w3-text-yellow,.w3-hover-text-yellow:hover{color:#d2be0e!important}
.w3-text-white,.w3-hover-text-white:hover{color:#fff!important}
.w3-text-black,.w3-hover-text-black:hover{color:#000!important}
.w3-text-grey,.w3-hover-text-grey:hover,.w3-text-gray,.w3-hover-text-gray:hover{color:#757575!important}
.w3-text-light-grey,.w3-hover-text-light-grey:hover,.w3-text-light-gray,.w3-hover-text-light-gray:hover{color:#f1f1f1!important}
.w3-text-dark-grey,.w3-hover-text-dark-grey:hover,.w3-text-dark-gray,.w3-hover-text-dark-gray:hover{color:#3a3a3a!important}
.w3-border-amber,.w3-hover-border-amber:hover{border-color:#ffc107!important}
.w3-border-aqua,.w3-hover-border-aqua:hover{border-color:#00ffff!important}
.w3-border-blue,.w3-hover-border-blue:hover{border-color:#2196F3!important}
.w3-border-light-blue,.w3-hover-border-light-blue:hover{border-color:#87CEEB!important}
.w3-border-brown,.w3-hover-border-brown:hover{border-color:#795548!important}
.w3-border-cyan,.w3-hover-border-cyan:hover{border-color:#00bcd4!important}
.w3-border-blue-grey,.w3-hover-border-blue-grey:hover,.w3-border-blue-gray,.w3-hover-border-blue-gray:hover{border-color:#607d8b!important}
.w3-border-green,.w3-hover-border-green:hover{border-color:#4CAF50!important}
.w3-border-light-green,.w3-hover-border-light-green:hover{border-color:#8bc34a!important}
.w3-border-indigo,.w3-hover-border-indigo:hover{border-color:#3f51b5!important}
.w3-border-khaki,.w3-hover-border-khaki:hover{border-color:#f0e68c!important}
.w3-border-lime,.w3-hover-border-lime:hover{border-color:#cddc39!important}
.w3-border-orange,.w3-hover-border-orange:hover{border-color:#ff9800!important}
.w3-border-deep-orange,.w3-hover-border-deep-orange:hover{border-color:#ff5722!important}
.w3-border-pink,.w3-hover-border-pink:hover{border-color:#e91e63!important}
.w3-border-purple,.w3-hover-border-purple:hover{border-color:#9c27b0!important}
.w3-border-deep-purple,.w3-hover-border-deep-purple:hover{border-color:#673ab7!important}
.w3-border-red,.w3-hover-border-red:hover{border-color:#f44336!important}
.w3-border-sand,.w3-hover-border-sand:hover{border-color:#fdf5e6!important}
.w3-border-teal,.w3-hover-border-teal:hover{border-color:#009688!important}
.w3-border-yellow,.w3-hover-border-yellow:hover{border-color:#ffeb3b!important}
.w3-border-white,.w3-hover-border-white:hover{border-color:#fff!important}
.w3-border-black,.w3-hover-border-black:hover{border-color:#000!important}
.w3-border-grey,.w3-hover-border-grey:hover,.w3-border-gray,.w3-hover-border-gray:hover{border-color:#9e9e9e!important}
.w3-border-light-grey,.w3-hover-border-light-grey:hover,.w3-border-light-gray,.w3-hover-border-light-gray:hover{border-color:#f1f1f1!important}
.w3-border-dark-grey,.w3-hover-border-dark-grey:hover,.w3-border-dark-gray,.w3-hover-border-dark-gray:hover{border-color:#616161!important}
.w3-border-pale-red,.w3-hover-border-pale-red:hover{border-color:#ffe7e7!important}.w3-border-pale-green,.w3-hover-border-pale-green:hover{border-color:#e7ffe7!important}
.w3-border-pale-yellow,.w3-hover-border-pale-yellow:hover{border-color:#ffffcc!important}.w3-border-pale-blue,.w3-hover-border-pale-blue:hover{border-color:#e7ffff!important}
+43
View File
@@ -0,0 +1,43 @@
<script setup lang="ts">
import { ref } from 'vue'
import { RouterLink, RouterView } from 'vue-router'
const menuDisplay = ref(false);
function w3_open(){
menuDisplay.value = true;
}
function w3_close(){
menuDisplay.value = false;
}
</script>
<template>
<!-- Sidebar (hidden by default) -->
<nav class="w3-sidebar w3-bar-block w3-card w3-top w3-xlarge w3-animate-left" :style="{'display':menuDisplay === true?'block':'none', 'z-index':'2', 'width':'40%', 'min-width':'300px'}" id="mySidebar">
<a href="javascript:void(0)" @click.prevent="w3_close"
class="w3-bar-item w3-button">Close Menu</a>
<RouterLink to="/" @click.prevent="w3_close" class="w3-bar-item w3-button">Home</RouterLink>
<RouterLink to="/board" @click.prevent="w3_close" class="w3-bar-item w3-button">Board</RouterLink>
</nav>
<!-- Top menu -->
<div class="w3-top">
<div class="w3-white w3-xlarge" style="max-width:1200px;margin:auto">
<div class="w3-button w3-padding-16 w3-left" @click.prevent="w3_open">☰</div>
<div class="w3-right w3-padding-16"></div>
<div class="w3-center w3-padding-16">Board</div>
</div>
</div>
<div class="w3-main w3-content w3-padding" style="max-width:1200px;margin-top:100px">
<RouterView />
</div>
</template>
<style scoped>
</style>
@@ -0,0 +1,86 @@
/* color palette from <https://github.com/vuejs/theme> */
:root {
--vt-c-white: #ffffff;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818;
--vt-c-black-soft: #222222;
--vt-c-black-mute: #282828;
--vt-c-indigo: #2c3e50;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-text-light-1: var(--vt-c-indigo);
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
--vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
}
/* semantic color variables for this project */
:root {
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--section-gap: 160px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
transition:
color 0.5s,
background-color 0.5s;
line-height: 1.6;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

After

Width:  |  Height:  |  Size: 276 B

@@ -0,0 +1,35 @@
@import './base.css';
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
font-weight: normal;
}
a,
.green {
text-decoration: none;
color: hsla(160, 100%, 37%, 1);
transition: 0.4s;
padding: 3px;
}
@media (hover: hover) {
a:hover {
background-color: hsla(160, 100%, 37%, 0.2);
}
}
@media (min-width: 1024px) {
body {
display: flex;
place-items: center;
}
#app {
display: grid;
grid-template-columns: 1fr 1fr;
padding: 0 2rem;
}
}
@@ -0,0 +1,70 @@
<template>
<div class="w3-bar w3-border w3-round">
<!-- TODO: 이전 그룹 버튼 (startPage가 1인 경우 비활성화 클래스 적용) -->
<a href="#" class="w3-bar-item w3-button" @click.prevent="onPageChange(1)">&laquo;&laquo;</a>
<a href="#" class="w3-bar-item w3-button" @click.prevent="onPageChange(currentPage - 1)">&laquo;</a>
<!-- TODO: 계산된 pageNumbers를 v-for로 반복 출력하고 현재 페이지(blue 클래스)를 표시하세요. -->
<a
href="#"
v-for="number in pageNumbers"
:key="number"
class="w3-bar-item w3-button"
:class="{ 'w3-blue': currentPage === number }"
@click.prevent="onPageChange(number)"
>
{{ number }}
</a>
<!-- TODO: 다음 그룹 버튼 (endPage가 totalPages인 경우 비활성화 클래스 적용) -->
<a href="#" class="w3-bar-item w3-button" @click.prevent="onPageChange(currentPage + 1)">&raquo;</a>
<a href="#" class="w3-bar-item w3-button" @click.prevent="onPageChange(totalPages)">&raquo;&raquo;</a>
</div>
</template>
<script lang="ts">
/**
* [실습 Phase 2] 공통 페이징 컴포넌트
*
* 가이드:
* 1. props로 currentPage, totalPages를 받으세요.
* 2. computed 속성을 사용하여 pageNumbers 배열을 동적으로 계산하세요.
* - startPage = Math.floor((currentPage - 1) / 10) * 10 + 1
* - endPage = Math.min(startPage + 9, totalPages)
* 3. 페이지 번호 클릭 시 this.$emit('page-change', pageNumber)를 호출하여 부모 컴포넌트(BoardList)에게 알리세요.
*/
import { defineComponent } from 'vue'
export default defineComponent({
name: 'Pagination',
props: {
// TODO: 부모로부터 받을 데이터를 정의하세요.
currentPage: { type: Number, required: true },
totalPages: { type: Number, required: true }
},
computed: {
pageNumbers(): number[] {
// TODO: 현재 그룹의 시작 페이지(startPage)와 끝 페이지(endPage)를 계산하여 배열을 반환하세요.
// 시작 페이지 =
const pagesGroup = 10;
const startPage = Math.floor((this.currentPage - 1) / pagesGroup) * pagesGroup + 1; // 로직 구현
const endPage = Math.min(startPage + 9, this.totalPages); // 로직 구현
const numbers: number[] = [];
// for문을 사용하여 numbers 배열 구성
console.log("startPage", startPage, "endPage", endPage);
for(let i = startPage; i <= endPage; i++){
numbers.push(i);
}
return numbers;
},
},
methods: {
onPageChange(page: number) {
// 범위 체크 후 이벤트를 발생시키세요.
if (page < 1 || page > this.totalPages) return;
this.$emit('page-change', page);
}
}
});
</script>
+18
View File
@@ -0,0 +1,18 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import axios, { type AxiosInstance } from 'axios'
declare module '@vue/runtime-core' {
interface ComponentCustomProperties {
$axios: AxiosInstance;
$serverUrl: string;
}
}
const app = createApp(App)
app.use(router)
app.config.globalProperties.$axios = axios
app.config.globalProperties.$serverUrl = import.meta.env.VITE_API_BASE_URL
app.mount('#app')
@@ -0,0 +1,41 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import BoardList from '@/views/BoardList.vue'
import BoardDetail from '@/views/BoardDetail.vue'
import BoardWrite from '@/views/BoardWrite.vue'
import BoardUpdate from '@/views/BoardUpdate.vue'
const routes: Array<RouteRecordRaw> = [
{
path: '/',
name: 'main',
component: BoardList,
},
{
path: '/board',
name: 'board',
component: BoardList,
},
{
path: '/board/detail/:idx',
name: 'boardDetail',
component: BoardDetail,
},
{
path: '/board/write',
name: 'boardWrite',
component: BoardWrite,
},
{
path: '/board/update',
name: 'boardUpdate',
component: BoardUpdate,
props: true,
},
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
})
export default router
@@ -0,0 +1,18 @@
export interface IBoardItem {
idx: number | string;
title: string;
contents: string;
creaNm: string;
creaDtm: string;
hitCnt: number | string;
rnum?: number;
}
export interface IBoardResponse {
list: IBoardItem[];
totalpage: number;
}
export interface IBoardDetailResponse {
detail: IBoardItem;
}
@@ -0,0 +1,130 @@
<template>
<div class="board-detail">
<!-- TODO: post 객체의 내용을 바인딩하세요. -->
<h1>{{ post.title }}</h1>
<p>{{ post.contents}}</p>
<div class="post-meta">
<span>글쓴이: {{post.creaNm}}</span>
<span>작성일: {{post.creaDtm}}</span>
</div>
</div>
<div class="w3-bar w3-border w3-margin-top">
<RouterLink to="/board" class="w3-bar-item w3-button w3-blue">목록</RouterLink>
<button @click="goUpdate()" class="w3-bar-item w3-button w3-green">수정</button>
<button @click="deletePost()" class="w3-bar-item w3-button w3-red">삭제</button>
</div>
</template>
<script lang="ts">
/**
* [실습 Phase 3] 게시글 상세 조회 컴포넌트
*
* 가이드:
* 1. this.$route.params.idx를 통해 URL에서 게시글 ID를 읽어오세요.
* 2. getPost() 메서드에서 axios.get을 사용하여 상세 정보를 가져오세요.
* - 엔드포인트: this.$serverUrl + '/board/api/detail/' + idx
* 3. mounted() 생명주기 훅에서 getPost()를 호출하세요.
* 4. deletePost() 메서드를 구현하여 실제 삭제가 이루어지도록 하세요.
* - 엔드포인트: this.$serverUrl + '/board/api/delete/' + idx (DELETE)
*/
import { defineComponent } from 'vue'
import type { IBoardItem, IBoardDetailResponse } from '../types/board'
import router from '@/router';
export default defineComponent({
name: 'BoardDetail',
data() {
return {
// TODO: 게시글 정보를 저장할 post 객체를 초기화하세요.
post: {
idx: '',
title: '',
contents: '',
hitCnt: '',
creaNm: '',
creaDtm: ''
} as IBoardItem
};
},
methods: {
getPost() {
// TODO: URL의 idx를 읽어와서 상세 정보를 조회하세요.
/*
const idx = this.$route.params.idx;
this.$axios.get<IBoardDetailResponse>(...)
.then(res => { ... })
*/
const idx = router.currentRoute.value.params.idx;
const URL = this.$serverUrl + "/board/api/detail/"+idx;
this.$axios.get<IBoardDetailResponse>(URL)
.then(res=>{
const detail = res.data.detail;
this.post.idx = detail.idx;
this.post.title = detail.title;
this.post.contents = detail.contents;
this.post.hitCnt = detail.hitCnt;
this.post.creaNm = detail.creaNm;
this.post.creaDtm = detail.creaDtm;
});
},
goUpdate() {
// TODO: 수정 페이지(boardUpdate)로 데이터를 넘기며 이동하세요.
router.push({
name: 'boardUpdate',
query: { idx: String(this.post.idx), title: this.post.title, contents: this.post.contents }
});
},
deletePost() {
// TODO: confirm() 확인 후 DELETE API를 호출하세요.
if (confirm('정말로 삭제하시겠습니까?')) {
// Axios 연동
const idx = this.post.idx;
const apiUrl = this.$serverUrl + '/board/api/detail/' + idx;
this.$axios.delete(apiUrl)
.then(res =>{
router.push({
name: 'board'
});
}).catch(err =>{
console.error(err);
alert("게시글 삭제가 실패하였습니다.");
})
}
}
},
mounted() {
// TODO: 초기 마운트 시 상세 데이터를 호출하세요.
this.getPost();
}
});
</script>
<style scoped>
.board-detail {
padding: 20px;
border: 1px solid #ddd;
border-radius: 4px;
background-color: #f9f9f9;
}
.board-detail h1 {
font-size: 24px;
margin-bottom: 10px;
}
.board-detail p {
font-size: 16px;
line-height: 1.5;
}
.post-meta {
margin-top: 20px;
font-size: 14px;
color: #666;
}
.post-meta span {
display: block;
margin-bottom: 5px;
}
</style>
@@ -0,0 +1,175 @@
<template>
<div class="w3-container w3-padding">
<!-- 검색 바 영역 -->
<div class="w3-bar w3-border w3-light-grey w3-round w3-margin-bottom">
<select class="w3-bar-item w3-select w3-border-right" style="width: 120px;" v-model="searchType">
<option value="title">제목</option>
<option value="content">내용</option>
<option value="creaNm">작성자</option>
</select>
<input
class="w3-bar-item w3-input"
type="text"
style="width: 250px;"
placeholder="검색어 입력"
v-model="searchTerm"
@keyup.enter="handleSearch"
>
<button class="w3-bar-item w3-button w3-blue" @click="handleSearch">검색</button>
</div>
<!-- 게시글 목록 테이블 영역 -->
<div class="board-list">
<div class="board-header">
<div class="board-cell">순번</div>
<div class="board-cell">제목</div>
<div class="board-cell">글쓴이</div>
<div class="board-cell">조회수</div>
<div class="board-cell">날짜</div>
</div>
<div class="board-body">
<!-- TODO: boardItems 배열을 v-for를 사용해 반복 렌더링하세요. -->
<div
class="board-row w3-hover-light-grey"
v-for="item in boardItems"
:key="item.idx"
@click="goDetail(item)"
style="cursor: pointer;"
>
<div class="board-cell">{{ item.rnum }}</div>
<div class="board-cell w3-text-blue" style="text-align: left; padding-left: 20px;">{{ item.title }}</div>
<div class="board-cell">{{ item.creaNm }}</div>
<div class="board-cell">{{ item.hitCnt }}</div>
<div class="board-cell">{{ item.creaDtm }}</div>
</div>
<!-- 데이터가 없을 때의 표시 (v-if 활용) -->
<div v-if="boardItems.length === 0" class="w3-center w3-padding-64">
<p>데이터가 없습니다. API 로직을 구현해 보세요!</p>
</div>
</div>
</div>
<!-- 페이징 및 글쓰기 버튼 영역 -->
<div class="w3-bar w3-margin-top" style="display: flex; justify-content: space-between; align-items: center;">
<div style="flex: 1; display: flex; justify-content: center;">
<!-- TODO: Pagination 컴포넌트에 props와 이벤트를 연결하세요. -->
<Pagination
:current-page="currentPage"
:total-pages="totalPages"
@page-change="handlePageChange"
/>
</div>
<RouterLink to="/board/write" class="w3-button w3-blue w3-round">글쓰기</RouterLink>
</div>
</div>
</template>
<script lang="ts">
/**
* [실습 Phase 1 & 5] 게시판 목록 및 검색 컴포넌트
*
* 가이드:
* 1. data()에 게시글 목록(boardItems), 검색 조건(searchType, searchTerm), 페이지 정보(currentPage, totalPages)를 정의하세요.
* 2. getBoardList() 메서드를 구현하여 서버에서 데이터를 가져오세요. (this.$axios.get 사용)
* - 엔드포인트: this.$serverUrl + '/board/api/list'
* - 파라미터: type, keyword, page
* 3. mounted() 생명주기 훅에서 getBoardList()를 호출하여 초기 데이터를 로드하세요.
* 4. handleSearch()와 handlePageChange()를 연동하여 검색 및 페이지 이동 기능을 완성하세요.
*/
import { defineComponent } from 'vue'
import type { IBoardItem, IBoardResponse } from '../types/board'
import Pagination from '../components/Pagination.vue'
import router from '@/router/index.ts';
export default defineComponent({
name: 'BoardList',
components: {
Pagination
},
data() {
return {
// TODO: 필요한 반응형 데이터를 정의하세요.
boardItems: [] as IBoardItem[],
searchType: 'title',
searchTerm: '',
currentPage: 1,
totalPages: 1,
};
},
methods: {
handleSearch() {
// TODO: 검색어 제출 시 페이지를 1로 초기화하고 데이터를 다시 불러오세요.
this.currentPage = 1;
this.getBoardList();
},
handlePageChange(page: number) {
// TODO: 페이지 변경 이벤트를 처리하세요.
this.currentPage = page;
this.getBoardList();
},
getBoardList() {
// TODO: Axios를 사용하여 API를 호출하세요.
/*
this.$axios.get<IBoardResponse>(URL, { params: { ... } })
.then(res => { ... })
*/
const URL = this.$serverUrl + "/board/api/list";
this.$axios.get<IBoardResponse>(URL, {
params: {
type: this.searchType,
keyword: this.searchTerm,
page: this.currentPage
}
}).then(res=>{
console.log("res", res);
this.boardItems = res.data.list;
this.totalPages = res.data.totalpage;
});
},
goDetail(item: IBoardItem) {
// TODO: 클릭 시 상세 페이지(boardDetail)로 이동하세요. ($router.push)
router.push({ name: 'boardDetail', params: { idx: String(item.idx) } });
},
},
mounted() {
// TODO: 컴포넌트가 마운트될 때 초기 목록을 로드하세요.
this.getBoardList();
},
});
</script>
<style scoped>
.board-list {
display: grid;
grid-template-columns: 1fr;
gap: 1px;
border: 1px solid #ccc;
border-radius: 4px;
overflow: hidden;
}
.board-header, .board-row {
display: grid;
grid-template-columns: 1fr 6fr 2fr 1fr 2fr;
background-color: white;
}
.board-cell {
padding: 12px 10px;
border-bottom: 1px solid #eee;
text-align: center;
align-content: center;
}
.board-header {
background-color: #f1f1f1;
font-weight: bold;
border-bottom: 2px solid #ccc;
}
.board-body .board-row:last-child .board-cell {
border-bottom: none;
}
</style>
@@ -0,0 +1,89 @@
<template>
<div class="w3-container w3-padding">
<h2>게시글 수정</h2>
<div class="w3-card-4">
<div class="w3-container w3-green">
<h4>Edit Post</h4>
</div>
<form class="w3-container w3-padding-16">
<label>제목</label>
<!-- TODO: v-model을 사용하여 title과 동기화하세요. -->
<input class="w3-input w3-border" type="text" v-model="post.title" required>
<label>비밀번호</label>
<input class="w3-input w3-border" type="password" v-model="post.password" required>
<label class="w3-margin-top">내용</label>
<!-- TODO: v-model을 사용하여 contents와 동기화하세요. -->
<textarea class="w3-input w3-border" rows="8" v-model="post.contents" required></textarea>
<div class="w3-margin-top">
<button type="button" class="w3-button w3-gray" @click="goBack()">취소</button>
<button type="button" class="w3-button w3-green w3-right" @click="updatePost()">수정</button>
</div>
</form>
</div>
</div>
</template>
<script lang="ts">
/**
* [실습 Phase 4] 게시글 수정 컴포넌트
*
* 가이드:
* 1. query 파라미터로 넘어온 데이터(this.$route.query)를 post 객체로 받으세요.
* 2. v-model로 폼을 바인딩하여 수정한 내용을 받으세요.
* 3. updatePost() 메서드를 구현하여 PUT 혹은 POST 요청을 보내세요.
* 4. 성공 시 마법처럼(?) 상세 페이지로 이동하게 만들어 보세요!
*/
import router from '@/router';
import { defineComponent } from 'vue'
export default defineComponent({
name: 'BoardUpdate',
data() {
return {
// TODO: 수정할 데이터 원본을 쿼리에서 가져와 초기화하세요.
post: {
idx: '',
title: '',
password: '',
contents: ''
}
};
},
mounted() {
// TODO: 쿼리 정보를 post 데이터에 바인딩하세요.
const query = router.currentRoute.value.query;
this.post.idx = String(query.idx || '');
this.post.title = String(query.title || '');
this.post.contents = String(query.contents || '');
},
methods: {
goBack(){
router.go(-1);
},
updatePost() {
// TODO: 수정된 데이터를 서버로 전송하세요.
/*
this.$axios.post(URL, this.post)
.then(res => { ... })
*/
const apiUrl = this.$serverUrl + '/board/api/detail/'+this.post.idx;
this.$axios.post(apiUrl, this.post)
.then(res =>{
console.log("res", res);
if(res.data.code == -1){
throw Error(res.data.msg);
}
alert("게시글을 수정하였습니다..");
router.push({ name: 'boardDetail', params: { idx: this.post.idx } });
}).catch(err =>{
console.error(err);
alert("게시글 수정에 실패하였습니다.");
})
}
}
});
</script>
@@ -0,0 +1,95 @@
<template>
<div class="w3-container w3-padding">
<h2>게시글 작성</h2>
<div class="w3-card-4">
<div class="w3-container w3-blue">
<h4>New Post</h4>
</div>
<form class="w3-container w3-padding-16">
<label>제목</label>
<!-- TODO: v-model을 사용하여 title과 동기화하세요. -->
<input class="w3-input w3-border" type="text" v-model="post.title" required>
<label class="w3-margin-top">작성자 아이디</label>
<!-- TODO: v-model을 사용하여 creaId과 동기화하세요. -->
<input class="w3-input w3-border" type="text" v-model="post.creaId" required>
<label class="w3-margin-top">비밀번호</label>
<!-- TODO: v-model을 사용하여 password과 동기화하세요. -->
<input class="w3-input w3-border" type="password" v-model="post.password" required>
<label class="w3-margin-top">내용</label>
<!-- TODO: v-model을 사용하여 contents와 동기화하세요. -->
<textarea class="w3-input w3-border" rows="5" v-model="post.contents" required></textarea>
<div class="w3-margin-top">
<RouterLink to="/board" class="w3-button w3-gray">취소</RouterLink>
<button type="button" class="w3-button w3-blue w3-right" @click="savePost()">저장</button>
</div>
</form>
</div>
<div class="w3-panel w3-light-gray w3-leftbar w3-border-blue w3-padding">
<p>힌트: Vue에서는 <strong>v-model</strong>을 사용하여 폼 데이터와 자바스크립트 데이터를 간단하게 양방향 바인딩할 수 있습니다.</p>
</div>
</div>
</template>
<script lang="ts">
/**
* [실습 Phase 4] 게시글 작성 컴포넌트
*
* 가이드:
* 1. data()에 post 객체(title, creaNm, contents)를 정의하세요.
* 2. v-model 디렉티브를 사용하여 입력 필드와 데이터를 바인딩하세요. (양방향 바인딩)
* 3. savePost() 메서드에서 axios.post를 사용하여 서버로 데이터를 전송하세요.
* - 엔드포인트: this.$serverUrl + '/board/api/write'
* - 보낼 데이터 형식은 JSON이며 가이드에 따라 FormData로 감쌀 수도 있습니다.
* 4. 성공 시 alert을 띄우고 this.$router.push({ name: 'board' })를 호출하여 목록으로 이동하세요.
*/
import router from '@/router';
import { defineComponent } from 'vue'
export default defineComponent({
name: 'BoardWrite',
data() {
return {
// TODO: 게시글 전송에 필요한 데이터 객체를 초기화하세요.
post: {
title: '',
creaId: '',
password: '',
contents: ''
}
};
},
methods: {
savePost() {
// TODO: 입력값 검증 후 Axios POST 요청을 보내세요.
/*
const apiUrl = this.$serverUrl + '/board/api/write';
this.$axios.post(apiUrl, this.post)
.then(res => { ... })
*/
if(this.post.title === ''
|| this.post.creaId === ''
|| this.post.password === ''){
alert('입력값을 제대로 입력해주세요');
return;
}
const apiUrl = this.$serverUrl + '/board/api/detail';
this.$axios.post(apiUrl, this.post)
.then(res =>{
alert("게시글을 생성하였습니다.");
router.push({
name: 'board'
});
}).catch(err =>{
console.error(err);
alert("게시글 등록이 실패하였습니다.");
})
}
}
});
</script>
@@ -0,0 +1,15 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"module": "ESNext",
"moduleResolution": "bundler",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
@@ -0,0 +1,16 @@
{
"extends": "@tsconfig/node22/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.config.*",
"playwright.config.*"
],
"compilerOptions": {
"composite": true,
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"types": ["node"]
}
}
@@ -0,0 +1,20 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueJsx(),
vueDevTools(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
})
@@ -0,0 +1,6 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true

Some files were not shown because too many files have changed in this diff Show More