diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md new file mode 100644 index 0000000..8555995 --- /dev/null +++ b/docs/FRONTEND.md @@ -0,0 +1,85 @@ +# Slim Budget — 프론트엔드 (sb_pt) + +Vue 3 + Vite 기반의 가계부·게시판 웹 애플리케이션 프론트엔드. + +## 기술 스택 +- **Vue 3** (` diff --git a/src/api/accountApi.js b/src/api/accountApi.js new file mode 100644 index 0000000..8588f8b --- /dev/null +++ b/src/api/accountApi.js @@ -0,0 +1,143 @@ +import http from './http' + +// 백엔드 /api/account 엔드포인트와 매핑 (본인 데이터만) +export const accountApi = { + list({ year, month, type, category, walletId, keyword, tagId } = {}) { + return http.get('/account/entries', { params: { year, month, type, category, walletId, keyword, tagId } }) + }, + summary({ year, month } = {}) { + return http.get('/account/summary', { params: { year, month } }) + }, + stats({ unit, year, month } = {}) { + return http.get('/account/stats', { params: { unit, year, month } }) + }, + categoryStats({ type, year, month } = {}) { + return http.get('/account/category-stats', { params: { type, year, month } }) + }, + netWorthTrend({ months } = {}) { + return http.get('/account/networth/trend', { params: { months } }) + }, + create(payload) { + return http.post('/account/entries', payload) + }, + update(id, payload) { + return http.put(`/account/entries/${id}`, payload) + }, + remove(id) { + return http.delete(`/account/entries/${id}`) + }, + + // 계좌/카드 (사용자별) + wallets() { + return http.get('/account/wallets') + }, + netWorth() { + return http.get('/account/networth') + }, + repayment(payload) { + return http.post('/account/repayment', payload) + }, + + // 정기/반복 거래 + recurrings() { + return http.get('/account/recurrings') + }, + createRecurring(payload) { + return http.post('/account/recurrings', payload) + }, + updateRecurring(id, payload) { + return http.put(`/account/recurrings/${id}`, payload) + }, + removeRecurring(id) { + return http.delete(`/account/recurrings/${id}`) + }, + runRecurrings() { + return http.post('/account/recurrings/run') + }, + + // 예산 (사용자별) + budgets() { + return http.get('/account/budgets') + }, + budgetStatus({ year, month }) { + return http.get('/account/budgets/status', { params: { year, month } }) + }, + budgetPeriod({ unit, year, month }) { + return http.get('/account/budgets/period', { params: { unit, year, month } }) + }, + createBudget(payload) { + return http.post('/account/budgets', payload) + }, + updateBudget(id, payload) { + return http.put(`/account/budgets/${id}`, payload) + }, + removeBudget(id) { + return http.delete(`/account/budgets/${id}`) + }, + createWallet(payload) { + return http.post('/account/wallets', payload) + }, + updateWallet(id, payload) { + return http.put(`/account/wallets/${id}`, payload) + }, + removeWallet(id) { + return http.delete(`/account/wallets/${id}`) + }, + walletEntries(id) { + return http.get(`/account/wallets/${id}/entries`) + }, + + // 투자 포트폴리오 (C) — 종목/매매 + investHoldings(walletId) { + return http.get('/account/invest/holdings', { params: { walletId } }) + }, + createHolding(payload) { + return http.post('/account/invest/holdings', payload) + }, + updateHolding(id, payload) { + return http.put(`/account/invest/holdings/${id}`, payload) + }, + removeHolding(id) { + return http.delete(`/account/invest/holdings/${id}`) + }, + holdingTrades(holdingId) { + return http.get(`/account/invest/holdings/${holdingId}/trades`) + }, + addTrade(holdingId, payload) { + return http.post(`/account/invest/holdings/${holdingId}/trades`, payload) + }, + removeTrade(id) { + return http.delete(`/account/invest/trades/${id}`) + }, + + // 분류(카테고리) — 사용자별 + categories() { + return http.get('/account/categories') + }, + createCategory(payload) { + return http.post('/account/categories', payload) + }, + updateCategory(id, payload) { + return http.put(`/account/categories/${id}`, payload) + }, + removeCategory(id) { + return http.delete(`/account/categories/${id}`) + }, + importCategories() { + return http.post('/account/categories/import') + }, + + // 사용자별 가계부 태그 + tags() { + return http.get('/account/tags') + }, + createTag(payload) { + return http.post('/account/tags', payload) + }, + updateTag(id, payload) { + return http.put(`/account/tags/${id}`, payload) + }, + removeTag(id) { + return http.delete(`/account/tags/${id}`) + }, +} diff --git a/src/api/adminApi.js b/src/api/adminApi.js new file mode 100644 index 0000000..3179d52 --- /dev/null +++ b/src/api/adminApi.js @@ -0,0 +1,32 @@ +import http from './http' + +// 관리자 태그 관리 API (/api/admin) +export const adminApi = { + categories() { + return http.get('/admin/tag-categories') + }, + createCategory(payload) { + return http.post('/admin/tag-categories', payload) + }, + updateCategory(id, payload) { + return http.put(`/admin/tag-categories/${id}`, payload) + }, + removeCategory(id) { + return http.delete(`/admin/tag-categories/${id}`) + }, + createTag(payload) { + return http.post('/admin/tags', payload) + }, + updateTag(id, payload) { + return http.put(`/admin/tags/${id}`, payload) + }, + removeTag(id) { + return http.delete(`/admin/tags/${id}`) + }, + getBoardSetting() { + return http.get('/admin/board-setting') + }, + setBoardSetting(tagCategoryId) { + return http.put('/admin/board-setting', { tagCategoryId }) + }, +} diff --git a/src/api/authApi.js b/src/api/authApi.js new file mode 100644 index 0000000..c150de8 --- /dev/null +++ b/src/api/authApi.js @@ -0,0 +1,17 @@ +import http from './http' + +// 백엔드 /api/auth 엔드포인트와 매핑 +export const authApi = { + signup(payload) { + return http.post('/auth/signup', payload) + }, + login(payload) { + return http.post('/auth/login', payload) + }, + logout() { + return http.post('/auth/logout') + }, + me() { + return http.get('/auth/me') + }, +} diff --git a/src/api/boardApi.js b/src/api/boardApi.js new file mode 100644 index 0000000..8efca36 --- /dev/null +++ b/src/api/boardApi.js @@ -0,0 +1,46 @@ +import http from './http' + +// 백엔드 /api/board 엔드포인트와 매핑 +export const boardApi = { + list({ page = 1, size = 10, tag = '', keyword = '', searchType = '' } = {}) { + return http.get('/board/posts', { + params: { + page, + size, + tag: tag || undefined, + keyword: keyword || undefined, + searchType: keyword ? searchType || 'title' : undefined, + }, + }) + }, + get(id) { + return http.get(`/board/posts/${id}`) + }, + create(payload) { + return http.post('/board/posts', payload) + }, + update(id, payload) { + return http.put(`/board/posts/${id}`, payload) + }, + remove(id) { + return http.delete(`/board/posts/${id}`) + }, + tags() { + return http.get('/board/tags') + }, + tagGroups() { + return http.get('/board/tag-groups') + }, + addComment(id, content) { + return http.post(`/board/posts/${id}/comments`, { content }) + }, + removeComment(commentId) { + return http.delete(`/board/comments/${commentId}`) + }, + block(id, reason) { + return http.post(`/board/posts/${id}/block`, { reason }) + }, + unblock(id) { + return http.post(`/board/posts/${id}/unblock`) + }, +} diff --git a/src/api/http.js b/src/api/http.js index 0fd9a48..4bffdc6 100644 --- a/src/api/http.js +++ b/src/api/http.js @@ -27,8 +27,10 @@ http.interceptors.response.use( (error) => { const status = error.response?.status if (status === 401) { - // 인증 만료 처리 등 - console.warn('인증이 필요합니다.') + // 세션 만료/무효: 클라이언트 저장값 정리 후 로그인 팝업 트리거 + localStorage.removeItem('token') + localStorage.removeItem('auth_user') + window.dispatchEvent(new CustomEvent('auth:unauthorized')) } return Promise.reject(error) }, diff --git a/src/assets/main.css b/src/assets/main.css index 36fb845..752207e 100644 --- a/src/assets/main.css +++ b/src/assets/main.css @@ -1,9 +1,6 @@ @import './base.css'; #app { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; font-weight: normal; } @@ -12,7 +9,6 @@ a, text-decoration: none; color: hsla(160, 100%, 37%, 1); transition: 0.4s; - padding: 3px; } @media (hover: hover) { @@ -21,15 +17,50 @@ a, } } -@media (min-width: 1024px) { - body { - display: flex; - place-items: center; - } - - #app { - display: grid; - grid-template-columns: 1fr 1fr; - padding: 0 2rem; - } +/* ===== Toast UI 코드 스타일 (에디터/뷰어 공통) ===== + 인라인 코드: 다크 배경 + 흰 글자 / 코드 블록: 다크 + 구문 강조(Prism) + Copy 버튼. + 클래스 중첩으로 우선순위를 높여 라이브러리 기본 스타일을 덮어쓴다. */ +.toastui-editor-contents.toastui-editor-contents :not(pre) > code { + color: #ffffff; + background-color: #1e1e1e; + padding: 0.2em 0.4em; + margin: 0; + font-size: 100%; + border-radius: 6px; + font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace; +} +.toastui-editor-contents.toastui-editor-contents pre { + position: relative; + background-color: #1e1e1e; + border-radius: 8px; + padding: 14px 16px; + overflow: auto; + font-size: 100%; + line-height: 1.5; +} +.toastui-editor-contents.toastui-editor-contents pre code { + background: transparent; + padding: 0; + font-size: 100%; + color: #f0f0f0; + font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace; +} + +/* 코드 블록 Copy 버튼 (JS로 주입되므로 전역 스타일) */ +.toastui-editor-contents pre .code-copy-btn { + position: absolute; + top: 8px; + right: 8px; + padding: 4px 12px; + font-size: 12px; + color: #ddd; + background: #2d2d2d; + border: 1px solid #444; + border-radius: 6px; + cursor: pointer; + transition: background 0.15s; +} +.toastui-editor-contents pre .code-copy-btn:hover { + background: #3a3a3a; + color: #fff; } diff --git a/src/components/LoginModal.vue b/src/components/LoginModal.vue new file mode 100644 index 0000000..8631235 --- /dev/null +++ b/src/components/LoginModal.vue @@ -0,0 +1,184 @@ + + + + + diff --git a/src/components/SignupModal.vue b/src/components/SignupModal.vue new file mode 100644 index 0000000..4bfe3eb --- /dev/null +++ b/src/components/SignupModal.vue @@ -0,0 +1,174 @@ + + + + + diff --git a/src/components/editor/MarkdownEditor.vue b/src/components/editor/MarkdownEditor.vue new file mode 100644 index 0000000..7e06967 --- /dev/null +++ b/src/components/editor/MarkdownEditor.vue @@ -0,0 +1,55 @@ + + + diff --git a/src/components/editor/MarkdownViewer.vue b/src/components/editor/MarkdownViewer.vue new file mode 100644 index 0000000..a409e7d --- /dev/null +++ b/src/components/editor/MarkdownViewer.vue @@ -0,0 +1,65 @@ + + + diff --git a/src/components/layout/AppFooter.vue b/src/components/layout/AppFooter.vue new file mode 100644 index 0000000..f3e6172 --- /dev/null +++ b/src/components/layout/AppFooter.vue @@ -0,0 +1,21 @@ + + + + + diff --git a/src/components/layout/AppHeader.vue b/src/components/layout/AppHeader.vue new file mode 100644 index 0000000..4b98762 --- /dev/null +++ b/src/components/layout/AppHeader.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/src/components/layout/AppSidebar.vue b/src/components/layout/AppSidebar.vue new file mode 100644 index 0000000..f686991 --- /dev/null +++ b/src/components/layout/AppSidebar.vue @@ -0,0 +1,99 @@ + + + + + diff --git a/src/components/ui/IconBtn.vue b/src/components/ui/IconBtn.vue new file mode 100644 index 0000000..a0cfe55 --- /dev/null +++ b/src/components/ui/IconBtn.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/src/router/index.js b/src/router/index.js index a803277..ad8da0a 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -1,5 +1,7 @@ import { createRouter, createWebHistory } from 'vue-router' import HomeView from '../views/HomeView.vue' +import { useAuthStore } from '@/stores/auth' +import { useUiStore } from '@/stores/ui' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -9,20 +11,100 @@ const router = createRouter({ name: 'home', component: HomeView, }, - { - path: '/about', - name: 'about', - // route level code-splitting - // this generates a separate chunk (About.[hash].js) for this route - // which is lazy-loaded when the route is visited. - component: () => import('../views/AboutView.vue'), - }, { path: '/users', name: 'users', component: () => import('../views/UsersView.vue'), + meta: { requiresAuth: true }, + }, + { + path: '/board', + name: 'board', + component: () => import('../views/board/BoardListView.vue'), + meta: { requiresAuth: true }, + }, + { + path: '/board/write', + name: 'board-write', + component: () => import('../views/board/BoardWriteView.vue'), + meta: { requiresAuth: true }, + }, + { + path: '/board/:id(\\d+)', + name: 'board-detail', + component: () => import('../views/board/BoardDetailView.vue'), + meta: { requiresAuth: true }, + }, + { + path: '/board/:id(\\d+)/edit', + name: 'board-edit', + component: () => import('../views/board/BoardWriteView.vue'), + meta: { requiresAuth: true }, + }, + { + path: '/admin/tags', + name: 'admin-tags', + component: () => import('../views/admin/TagAdminView.vue'), + meta: { requiresAuth: true, requiresAdmin: true }, + }, + { + path: '/account', + name: 'account', + component: () => import('../views/account/AccountDashboardView.vue'), + meta: { requiresAuth: true }, + }, + { + path: '/account/entries', + name: 'account-entries', + component: () => import('../views/account/AccountView.vue'), + meta: { requiresAuth: true }, + }, + { + path: '/account/tags', + name: 'account-tags', + component: () => import('../views/account/AccountTagView.vue'), + meta: { requiresAuth: true }, + }, + { + path: '/account/wallets', + name: 'account-wallets', + component: () => import('../views/account/AccountWalletView.vue'), + meta: { requiresAuth: true }, + }, + { + path: '/account/categories', + name: 'account-categories', + component: () => import('../views/account/CategoryView.vue'), + meta: { requiresAuth: true }, + }, + { + path: '/account/budget', + name: 'account-budget', + component: () => import('../views/account/BudgetView.vue'), + meta: { requiresAuth: true }, + }, + { + path: '/account/recurrings', + name: 'account-recurrings', + component: () => import('../views/account/RecurringView.vue'), + meta: { requiresAuth: true }, }, ], }) +// 전역 네비게이션 가드 +router.beforeEach((to, from) => { + const auth = useAuthStore() + // 인증이 필요한 페이지인데 미로그인 → 로그인 팝업 오픈 (원래 목적지 보존), 이동은 취소/홈 + if (to.meta.requiresAuth && !auth.isAuthenticated) { + const ui = useUiStore() + ui.openLogin(to.fullPath) + return from.name ? false : { name: 'home' } + } + // 관리자 전용 페이지 — 비관리자는 홈으로 + if (to.meta.requiresAdmin && auth.user?.role !== 'ADMIN') { + return { name: 'home' } + } +}) + export default router diff --git a/src/stores/auth.js b/src/stores/auth.js new file mode 100644 index 0000000..28d289f --- /dev/null +++ b/src/stores/auth.js @@ -0,0 +1,67 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { authApi } from '@/api/authApi' + +// 세션은 Redis(서버) + localStorage(클라이언트) 양쪽에 보관한다. +const TOKEN_KEY = 'token' +const USER_KEY = 'auth_user' + +export const useAuthStore = defineStore('auth', () => { + const token = ref(localStorage.getItem(TOKEN_KEY) || '') + const user = ref(safeParse(localStorage.getItem(USER_KEY))) + + const isAuthenticated = computed(() => !!token.value) + + function persist() { + if (token.value) localStorage.setItem(TOKEN_KEY, token.value) + else localStorage.removeItem(TOKEN_KEY) + + if (user.value) localStorage.setItem(USER_KEY, JSON.stringify(user.value)) + else localStorage.removeItem(USER_KEY) + } + + async function login(loginId, password) { + const res = await authApi.login({ loginId, password }) + token.value = res.token + user.value = res.member + persist() + return res + } + + async function signup(payload) { + return authApi.signup(payload) + } + + // 서버 세션 기준으로 현재 사용자 정보 동기화 (토큰 유효성 확인 용도) + async function fetchMe() { + const me = await authApi.me() + user.value = { ...(user.value || {}), ...me } + persist() + return me + } + + async function logout() { + try { + await authApi.logout() + } catch { + // 서버 세션이 이미 만료됐어도 클라이언트는 정리 + } + clear() + } + + function clear() { + token.value = '' + user.value = null + persist() + } + + return { token, user, isAuthenticated, login, signup, fetchMe, logout, clear } +}) + +function safeParse(value) { + try { + return value ? JSON.parse(value) : null + } catch { + return null + } +} diff --git a/src/stores/ui.js b/src/stores/ui.js new file mode 100644 index 0000000..50b747a --- /dev/null +++ b/src/stores/ui.js @@ -0,0 +1,43 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' + +// 전역 UI 상태 (로그인/회원가입 레이어 팝업 등) +export const useUiStore = defineStore('ui', () => { + const loginOpen = ref(false) + const signupOpen = ref(false) + const redirectPath = ref('') + + // 모바일 사이드바 드로어 + const sidebarOpen = ref(false) + function toggleSidebar() { + sidebarOpen.value = !sidebarOpen.value + } + function closeSidebar() { + sidebarOpen.value = false + } + + // 로그인 팝업 (회원가입 팝업은 닫고 전환) + function openLogin(redirect = '') { + redirectPath.value = redirect || '' + signupOpen.value = false + loginOpen.value = true + } + function closeLogin() { + loginOpen.value = false + redirectPath.value = '' + } + + // 회원가입 팝업 (로그인 팝업은 닫고 전환) + function openSignup() { + loginOpen.value = false + signupOpen.value = true + } + function closeSignup() { + signupOpen.value = false + } + + return { + loginOpen, signupOpen, redirectPath, openLogin, closeLogin, openSignup, closeSignup, + sidebarOpen, toggleSidebar, closeSidebar, + } +}) diff --git a/src/utils/datetime.js b/src/utils/datetime.js new file mode 100644 index 0000000..b9d8aa2 --- /dev/null +++ b/src/utils/datetime.js @@ -0,0 +1,31 @@ +// 날짜/시간 표시 유틸 + +/** + * 1일 이내면 "방금 전 / N분 전 / N시간 전", 그 이상이면 YYYYMMDD 로 표시. + */ +export function formatRelative(value) { + if (!value) return '-' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return String(value) + + const diffMs = Date.now() - date.getTime() + const diffMin = Math.floor(diffMs / 60000) + + if (diffMin < 1) return '방금 전' + if (diffMin < 60) return `${diffMin}분 전` + + const diffHour = Math.floor(diffMin / 60) + if (diffHour < 24) return `${diffHour}시간 전` + + return formatYmd(date) +} + +/** YYYYMMDD */ +export function formatYmd(value) { + const date = value instanceof Date ? value : new Date(value) + if (Number.isNaN(date.getTime())) return String(value) + const y = date.getFullYear() + const m = String(date.getMonth() + 1).padStart(2, '0') + const d = String(date.getDate()).padStart(2, '0') + return `${y}${m}${d}` +} diff --git a/src/views/AboutView.vue b/src/views/AboutView.vue deleted file mode 100644 index 756ad2a..0000000 --- a/src/views/AboutView.vue +++ /dev/null @@ -1,15 +0,0 @@ - - - diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue index 6bb706f..c79c554 100644 --- a/src/views/HomeView.vue +++ b/src/views/HomeView.vue @@ -1,9 +1,31 @@ + + diff --git a/src/views/UsersView.vue b/src/views/UsersView.vue index 2c6f365..da940c9 100644 --- a/src/views/UsersView.vue +++ b/src/views/UsersView.vue @@ -2,6 +2,7 @@ import { onMounted, reactive, ref } from 'vue' import { storeToRefs } from 'pinia' import { useUserStore } from '@/stores/user' +import IconBtn from '@/components/ui/IconBtn.vue' const userStore = useUserStore() const { users, loading, error } = storeToRefs(userStore) @@ -58,14 +59,12 @@ function formatDate(value) {
- +

{{ formError }}

- + 불러오는 중... 총 {{ users.length }}명
@@ -88,7 +87,7 @@ function formatDate(value) { {{ user.email }} {{ formatDate(user.createdAt) }} - + @@ -173,4 +172,52 @@ button.danger:hover { .msg.error { color: #c0392b; } + +@media (max-width: 768px) { + .users { + padding: 1rem; + max-width: 100%; + } + .user-table thead { + display: none; + } + .user-table, + .user-table tbody, + .user-table tr, + .user-table td { + display: block; + } + .user-table tr { + display: flex; + flex-wrap: wrap; + align-items: baseline; + column-gap: 0.6rem; + row-gap: 0.15rem; + padding: 0.6rem 0.1rem; + border-bottom: 1px solid var(--color-border); + } + .user-table td { + border: 0; + padding: 0; + font-size: 0.85rem; + } + .user-table td:nth-child(1)::before { + content: '#'; + } + .user-table td:nth-child(2) { + font-weight: 600; + font-size: 0.95rem; + } + .user-table td:nth-child(3) { + width: 100%; + opacity: 0.75; + font-size: 0.8rem; + } + .user-table td:nth-child(4) { + display: none; /* 생성일은 모바일에서 숨김 */ + } + .user-table td:nth-child(5) { + margin-left: auto; + } +} diff --git a/src/views/account/AccountDashboardView.vue b/src/views/account/AccountDashboardView.vue new file mode 100644 index 0000000..612efd7 --- /dev/null +++ b/src/views/account/AccountDashboardView.vue @@ -0,0 +1,862 @@ + + + + + diff --git a/src/views/account/AccountTagView.vue b/src/views/account/AccountTagView.vue new file mode 100644 index 0000000..e13b57f --- /dev/null +++ b/src/views/account/AccountTagView.vue @@ -0,0 +1,153 @@ + + + + + diff --git a/src/views/account/AccountView.vue b/src/views/account/AccountView.vue new file mode 100644 index 0000000..7f47d9f --- /dev/null +++ b/src/views/account/AccountView.vue @@ -0,0 +1,898 @@ + + + + + diff --git a/src/views/account/AccountWalletView.vue b/src/views/account/AccountWalletView.vue new file mode 100644 index 0000000..62121d1 --- /dev/null +++ b/src/views/account/AccountWalletView.vue @@ -0,0 +1,659 @@ + + + + + diff --git a/src/views/account/BudgetView.vue b/src/views/account/BudgetView.vue new file mode 100644 index 0000000..7c93376 --- /dev/null +++ b/src/views/account/BudgetView.vue @@ -0,0 +1,544 @@ + + + + + diff --git a/src/views/account/CategoryView.vue b/src/views/account/CategoryView.vue new file mode 100644 index 0000000..12fe384 --- /dev/null +++ b/src/views/account/CategoryView.vue @@ -0,0 +1,197 @@ + + + + + diff --git a/src/views/account/InvestPortfolio.vue b/src/views/account/InvestPortfolio.vue new file mode 100644 index 0000000..9adfcae --- /dev/null +++ b/src/views/account/InvestPortfolio.vue @@ -0,0 +1,588 @@ + + + + + diff --git a/src/views/account/RecurringView.vue b/src/views/account/RecurringView.vue new file mode 100644 index 0000000..91b3424 --- /dev/null +++ b/src/views/account/RecurringView.vue @@ -0,0 +1,467 @@ + + + + + diff --git a/src/views/admin/TagAdminView.vue b/src/views/admin/TagAdminView.vue new file mode 100644 index 0000000..bab6c6d --- /dev/null +++ b/src/views/admin/TagAdminView.vue @@ -0,0 +1,266 @@ + + + + + diff --git a/src/views/board/BoardDetailView.vue b/src/views/board/BoardDetailView.vue new file mode 100644 index 0000000..ac50136 --- /dev/null +++ b/src/views/board/BoardDetailView.vue @@ -0,0 +1,321 @@ + + + + + diff --git a/src/views/board/BoardListView.vue b/src/views/board/BoardListView.vue new file mode 100644 index 0000000..54c28c5 --- /dev/null +++ b/src/views/board/BoardListView.vue @@ -0,0 +1,473 @@ + + + + + diff --git a/src/views/board/BoardWriteView.vue b/src/views/board/BoardWriteView.vue new file mode 100644 index 0000000..e33d3ad --- /dev/null +++ b/src/views/board/BoardWriteView.vue @@ -0,0 +1,202 @@ + + + + +