Merge branch 'comment'
This commit is contained in:
commit
691ac86d05
@ -89,7 +89,6 @@ body {
|
|||||||
color 0.5s,
|
color 0.5s,
|
||||||
background-color 0.5s;
|
background-color 0.5s;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
font-size: 15px;
|
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
@ -116,4 +115,9 @@ textarea {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
background-color: var(--color-background);
|
background-color: var(--color-background);
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
white-space: pre;
|
||||||
}
|
}
|
||||||
|
@ -3,16 +3,41 @@ import { ref } from 'vue';
|
|||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import BoardHeader from './BoardHeader.vue';
|
import BoardHeader from './BoardHeader.vue';
|
||||||
import { getArticle } from '@/api/article';
|
import { getArticle } from '@/api/article';
|
||||||
import CommentList from '@/components/article/CommentList.vue';
|
import CommentArea from './CommentArea.vue';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
const id = Number(route.params.id);
|
const id = Number(route.params.id);
|
||||||
const article = ref({});
|
const article = ref({});
|
||||||
|
const comments = ref([]);
|
||||||
|
|
||||||
getArticle(id, ({ data }) => {
|
getArticle(id, ({ data }) => {
|
||||||
article.value = data;
|
article.value = data;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// FIXME: remove mock data
|
||||||
|
setTimeout(() => {
|
||||||
|
article.value = {
|
||||||
|
title: '제목',
|
||||||
|
text: '내용',
|
||||||
|
author: '작성자',
|
||||||
|
date: '2024-04-11 13:12:14',
|
||||||
|
};
|
||||||
|
comments.value = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
nickname: '닉네임',
|
||||||
|
date: '2024-04-11 13:12:14',
|
||||||
|
text: '댓글 내용',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
nickname: '닉네임',
|
||||||
|
date: '2024-04-11 13:12:14',
|
||||||
|
text: '댓글 내용',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, 100);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -26,8 +51,7 @@ getArticle(id, ({ data }) => {
|
|||||||
</template>
|
</template>
|
||||||
</BoardHeader>
|
</BoardHeader>
|
||||||
<p>{{ article.text }}</p>
|
<p>{{ article.text }}</p>
|
||||||
|
<CommentArea :article-id="id" :comments="comments" />
|
||||||
<CommentList :id="id"></CommentList>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@ -36,6 +60,5 @@ p {
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
padding-bottom: 20px;
|
padding-bottom: 20px;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
white-space: pre;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -89,12 +89,12 @@ form {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#title {
|
#title {
|
||||||
font-size: 1.25rem;
|
font-size: 20px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
#text {
|
#text {
|
||||||
font-size: 1rem;
|
font-size: 16px;
|
||||||
min-height: 200px;
|
min-height: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -106,7 +106,7 @@ form {
|
|||||||
|
|
||||||
.errorMessage {
|
.errorMessage {
|
||||||
color: var(--color-error);
|
color: var(--color-error);
|
||||||
font-size: 0.85rem;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
@ -116,7 +116,7 @@ button {
|
|||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 1rem;
|
font-size: 16px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
transition:
|
transition:
|
||||||
scale 0.25s,
|
scale 0.25s,
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
import { getArticles } from '@/api/article';
|
import { getArticles } from '@/api/article';
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { RouterLink } from 'vue-router';
|
import { RouterLink } from 'vue-router';
|
||||||
|
import FilledButton from '../common/FilledButton.vue';
|
||||||
|
|
||||||
const articles = ref([]);
|
const articles = ref([]);
|
||||||
|
|
||||||
@ -11,7 +12,9 @@ getArticles(({ data }) => (articles.value = data));
|
|||||||
<template>
|
<template>
|
||||||
<header>
|
<header>
|
||||||
<h1>게시판</h1>
|
<h1>게시판</h1>
|
||||||
<RouterLink class="write" :to="{ name: 'article-create' }">글쓰기</RouterLink>
|
<RouterLink :to="{ name: 'article-create' }">
|
||||||
|
<FilledButton primary class="write">글쓰기</FilledButton>
|
||||||
|
</RouterLink>
|
||||||
</header>
|
</header>
|
||||||
<ul>
|
<ul>
|
||||||
<li v-for="article in articles" :key="article.id">
|
<li v-for="article in articles" :key="article.id">
|
||||||
@ -47,14 +50,7 @@ header {
|
|||||||
.write {
|
.write {
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background-color: var(--color-primary);
|
font-size: 16px;
|
||||||
color: var(--color-background);
|
|
||||||
text-decoration: none;
|
|
||||||
transition: scale 0.25s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.write:active {
|
|
||||||
scale: 0.95;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
@ -65,7 +61,7 @@ header {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.title h2 {
|
.title h2 {
|
||||||
font-size: 1.1rem;
|
font-size: 18px;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -107,7 +103,7 @@ header {
|
|||||||
.info {
|
.info {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
font-size: 0.8rem;
|
font-size: 14px;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -44,6 +44,7 @@ header {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
font-size: 14px;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
34
src/components/article/CommentArea.vue
Normal file
34
src/components/article/CommentArea.vue
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<script setup>
|
||||||
|
import CommentForm from './CommentForm.vue';
|
||||||
|
import CommentList from './CommentList.vue';
|
||||||
|
|
||||||
|
const { articleId, comments } = defineProps({ articleId: Number, comments: Array });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="comments">
|
||||||
|
<h2>
|
||||||
|
댓글 <span>{{ comments.length }}</span>
|
||||||
|
</h2>
|
||||||
|
<CommentForm :article-id="articleId" />
|
||||||
|
<CommentList :comments="comments" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 span {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin: 0 20px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
82
src/components/article/CommentForm.vue
Normal file
82
src/components/article/CommentForm.vue
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import FilledButton from '../common/FilledButton.vue';
|
||||||
|
import TextButton from '../common/TextButton.vue';
|
||||||
|
|
||||||
|
const { id } = defineProps({ id: Number });
|
||||||
|
const isActive = ref(false);
|
||||||
|
const textDiv = ref(null);
|
||||||
|
const text = ref('');
|
||||||
|
|
||||||
|
function handleFocus(e) {
|
||||||
|
console.log(e);
|
||||||
|
isActive.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
text.value = '';
|
||||||
|
isActive.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
console.log(id, text.value);
|
||||||
|
// TODO: add api call
|
||||||
|
text.value = '';
|
||||||
|
isActive.value = false;
|
||||||
|
textDiv.value.blur();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<form action="POST" @submit.prevent="handleSubmit">
|
||||||
|
<input type="text" ref="textDiv" v-model="text" @focus="handleFocus" placeholder="댓글 쓰기" />
|
||||||
|
<div class="control" v-if="isActive">
|
||||||
|
<TextButton @click="handleCancel">취소</TextButton>
|
||||||
|
<FilledButton primary type="submit">등록</FilledButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control {
|
||||||
|
display: flex;
|
||||||
|
justify-self: flex-start;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) and (pointer: fine) {
|
||||||
|
button:hover {
|
||||||
|
background-color: var(--color-background-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
button[type='submit']:hover {
|
||||||
|
background-color: var(--color-primary-soft);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
button:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
background-color: var(--color-background-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
button[type='submit'] {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: var(--color-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
button[type='submit']:active {
|
||||||
|
background-color: var(--color-primary-soft);
|
||||||
|
}
|
||||||
|
</style>
|
50
src/components/article/CommentItem.vue
Normal file
50
src/components/article/CommentItem.vue
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import TextButton from '../common/TextButton.vue';
|
||||||
|
|
||||||
|
const { comment } = defineProps({ comment: Object });
|
||||||
|
const isDeleted = ref(false);
|
||||||
|
|
||||||
|
function handleDelete() {
|
||||||
|
console.log('delete', comment.id);
|
||||||
|
// TODO: add api call
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<li v-if="!isDeleted">
|
||||||
|
<div class="header">
|
||||||
|
<div class="info">
|
||||||
|
<div class="nickname">{{ comment.nickname }}</div>
|
||||||
|
<div class="date">{{ comment.date }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="!comment.isAuthor" class="control">
|
||||||
|
<TextButton @click="handleDelete">삭제</TextButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text">{{ comment.text }}</p>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
li {
|
||||||
|
padding: 16px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info,
|
||||||
|
.control {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
@ -1,41 +1,18 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue';
|
import CommentItem from './CommentItem.vue';
|
||||||
import { getComment } from '@/api/comment';
|
|
||||||
|
|
||||||
const props = defineProps({ id: Number });
|
const { comments } = defineProps({ comments: Array });
|
||||||
const id = props.id;
|
|
||||||
|
|
||||||
const comments = ref([]);
|
|
||||||
const text = ref('');
|
|
||||||
|
|
||||||
getComment(id, ({ data }) => {
|
|
||||||
comments.value = data;
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
|
||||||
<h2>댓글</h2>
|
|
||||||
<input type="text" ref="textDiv" v-model="text" />
|
|
||||||
</div>
|
|
||||||
<ul>
|
<ul>
|
||||||
<li v-for="comment in comments" :key="comment.id">
|
<CommentItem v-for="comment in comments" :key="comment.id" :comment="comment" />
|
||||||
<div class="info">
|
|
||||||
<div class="nickname">{{ comment.nickname }}</div>
|
|
||||||
<div class="date">{{ comment.date }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="info">
|
|
||||||
<div class="text">{{ comment.text }}</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.info {
|
ul {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 20px;
|
flex-direction: column;
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
56
src/components/common/FilledButton.vue
Normal file
56
src/components/common/FilledButton.vue
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
<script setup>
|
||||||
|
const { type, primary } = defineProps({
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
default: 'button',
|
||||||
|
},
|
||||||
|
primary: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button :type="type" :class="primary ? 'primary' : ''">
|
||||||
|
<slot></slot>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
button {
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
background-color: var(--color-background-soft);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
transition:
|
||||||
|
background-color 0.25s,
|
||||||
|
transform 0.25s;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: var(--color-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) and (pointer: fine) {
|
||||||
|
button:hover {
|
||||||
|
background-color: var(--color-background-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary:hover {
|
||||||
|
background-color: var(--color-primary-soft);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
button:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary:active {
|
||||||
|
background-color: var(--color-primary-soft);
|
||||||
|
}
|
||||||
|
</style>
|
48
src/components/common/TextButton.vue
Normal file
48
src/components/common/TextButton.vue
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const { type, primary, filled } = defineProps({
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
default: 'button',
|
||||||
|
},
|
||||||
|
primary: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const classes = computed(() => [primary ? 'primary' : '', filled ? 'filled' : '']);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button :type="type" :class="classes">
|
||||||
|
<slot></slot>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
button {
|
||||||
|
border: none;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
background: none;
|
||||||
|
padding: 0 4px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
transition: transform 0.25s;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) and (pointer: fine) {
|
||||||
|
button:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
button:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
</style>
|
@ -19,12 +19,12 @@ const router = createRouter({
|
|||||||
component: () => import('@/components/article/ArticleList.vue'),
|
component: () => import('@/components/article/ArticleList.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/article/:id',
|
path: 'article/:id',
|
||||||
name: 'article',
|
name: 'article',
|
||||||
component: () => import('@/components/article/ArticleDetail.vue'),
|
component: () => import('@/components/article/ArticleDetail.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/article/form',
|
path: 'article/form',
|
||||||
name: 'article-create',
|
name: 'article-create',
|
||||||
component: () => import('@/components/article/ArticleForm.vue'),
|
component: () => import('@/components/article/ArticleForm.vue'),
|
||||||
},
|
},
|
||||||
|
Loading…
Reference in New Issue
Block a user