Merge remote-tracking branch 'origin/main' into wordDict

This commit is contained in:
Dang 2025-02-04 11:03:11 +09:00
commit ff74a7477b
13 changed files with 490 additions and 240 deletions

15
package-lock.json generated
View File

@ -18,6 +18,7 @@
"axios": "^1.7.9", "axios": "^1.7.9",
"bootstrap": "^5.3.3", "bootstrap": "^5.3.3",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"dompurify": "^3.2.3",
"flatpickr": "^4.6.13", "flatpickr": "^4.6.13",
"front": "file:", "front": "file:",
"heic2any": "^0.0.4", "heic2any": "^0.0.4",
@ -1483,6 +1484,12 @@
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"dev": true "dev": true
}, },
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"optional": true
},
"node_modules/@vitejs/plugin-vue": { "node_modules/@vitejs/plugin-vue": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.1.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.1.tgz",
@ -2258,6 +2265,14 @@
"node": ">=0.4.0" "node": ">=0.4.0"
} }
}, },
"node_modules/dompurify": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.3.tgz",
"integrity": "sha512-U1U5Hzc2MO0oW3DF+G9qYN0aT7atAou4AgI0XjWz061nyBPbdxkfdhfy5uMgGn6+oLFCfn44ZGbdDqCzVmlOWA==",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",

View File

@ -21,6 +21,7 @@
"axios": "^1.7.9", "axios": "^1.7.9",
"bootstrap": "^5.3.3", "bootstrap": "^5.3.3",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"dompurify": "^3.2.3",
"flatpickr": "^4.6.13", "flatpickr": "^4.6.13",
"front": "file:", "front": "file:",
"heic2any": "^0.0.4", "heic2any": "^0.0.4",

View File

@ -1,56 +1,77 @@
<template> <template>
<ul class="list-unstyled"> <div>
<li> <BoardProfile :profileName="comment.author" :showDetail="false" :author="true" :isChild="isChild" />
<BoardProfile profileName=곤데리 :showDetail="false" :author="true" /> <div class="mt-6">
<div class="mt-2">저도 궁금합니다.</div> <p class="m-0">{{ comment.content }}</p>
<PlusButton @click="toggleComment"/> </div>
<BoardComentArea v-if="comment" /> <PlusButton v-if="isPlusButton" @click="toggleComment" class="mt-6"/>
<ul class="list-unstyled twoDepth"> <BoardComentArea v-if="isComment" @submit="submitComment"/>
<li>
<BoardProfile profileName=곤데리2 :showDetail="false" /> <!-- 대댓글 -->
<div class="mt-2">저도 궁금합니다.</div> <ul v-if="comment.children && comment.children.length" class="list-unstyled">
<BoardComentArea v-if="comment" /> <li
</li> v-for="child in comment.children"
</ul> :key="child.id"
</li> class="pt-8 ps-10"
<li> >
<BoardProfile profileName=곤데리 :showDetail="false" /> <BoardComment :comment="child" :isPlusButton="false" :isChild="true" @submitComment="addChildComment" />
<div class="mt-2">저도 궁금합니다.</div> </li>
<PlusButton @click="toggleComment"/> </ul>
<BoardComentArea v-if="comment" /> <!-- <ul class="list-unstyled twoDepth">
</li> <li>
<li> <BoardProfile profileName=곤데리2 :showDetail="false" />
<BoardProfile profileName=곤데리 :showDetail="false" /> <div class="mt-2">저도 궁금합니다.</div>
<div class="mt-2">저도 궁금합니다.</div> <BoardComentArea v-if="comment" />
<PlusButton @click="toggleComment"/> </li>
<BoardComentArea v-if="comment" /> </ul> -->
</li> <!-- <BoardProfile profileName=곤데리 :showDetail="false" />
</ul> <div class="mt-2">저도 궁금합니다.</div>
<Pagination/> <PlusButton @click="toggleComment"/>
<BoardComentArea v-if="comment" /> -->
</div>
</template> </template>
<script setup> <script setup>
import BoardProfile from './BoardProfile.vue'; import BoardProfile from './BoardProfile.vue';
import BoardComentArea from './BoardComentArea.vue'; import BoardComentArea from './BoardComentArea.vue';
import { ref, computed } from 'vue';
import Pagination from '../pagination/Pagination.vue';
import PlusButton from '../button/PlusBtn.vue'; import PlusButton from '../button/PlusBtn.vue';
import { ref } from 'vue';
import { defineEmits } from 'vue'; import { defineEmits } from 'vue';
const comment = ref(false); const props = defineProps({
comment: {
const toggleComment = () => { type: Object,
comment.value = !comment.value required: true,
}; },
isPlusButton: {
type: Boolean,
default: true,
},
isChild: {
type: Boolean,
default: false,
},
});
// emits // emits
const emit = defineEmits(['submitComment']); const emit = defineEmits(['submitComment']);
//
const isComment = ref(false);
const toggleComment = () => {
isComment.value = !isComment.value;
};
//
const addChildComment = (parentId, newComment) => {
emit('submitComment', parentId, newComment);
};
</script> </script>
<style scoped> <style scoped>
.twoDepth { /* .twoDepth {
margin-top: 10px; margin-top: 10px;
padding-left: 40px; padding-left: 40px;
} }
@ -66,5 +87,5 @@ const emit = defineEmits(['submitComment']);
.btn-text-primary:active, .btn-text-primary:active,
.btn-text-primary:focus { .btn-text-primary:focus {
background-color: transparent background-color: transparent
} } */
</style> </style>

View File

@ -0,0 +1,48 @@
<template>
<ul class="list-unstyled mt-10">
<li
v-for="comment in comments"
:key="comment.id"
class="mt-8"
>
<BoardComment :comment="comment" @submitComment="addComment" />
</li>
</ul>
</template>
<script setup>
import { ref } from 'vue';
import BoardComment from './BoardComment.vue'
const comments = ref([
{
id: 1,
author: '홍길동',
content: '저도 궁금합니다.',
children: [
{
id: 2,
author: '사용자1',
content: '저도요!',
},
{
id: 3,
author: '사용자2',
content: '저도..',
},
],
},
{
id: 4,
author: '사용자4',
content: '흥미로운 주제네요.',
children: [],
},
{
id: 5,
author: '사용자5',
content: '우오아아아아아앙',
children: [],
},
]);
</script>

View File

@ -3,7 +3,7 @@
<div class="d-flex justify-content-between align-items-center flex-wrap mb-6 gap-2"> <div class="d-flex justify-content-between align-items-center flex-wrap mb-6 gap-2">
<!-- 제목 섹션 --> <!-- 제목 섹션 -->
<div class="me-1"> <div class="me-1">
<h5 class="mb-0">{{ boardTitle }}</h5> <h5 class="mb-0">{{ boardTitle }}adada</h5>
</div> </div>
<!-- 첨부파일 섹션 --> <!-- 첨부파일 섹션 -->
<div v-if="dropdownItems.length > 0" class="btn-group"> <div v-if="dropdownItems.length > 0" class="btn-group">

View File

@ -10,13 +10,13 @@
<span>2024.12.10 10:46</span> <span>2024.12.10 10:46</span>
<template v-if="showDetail"> <template v-if="showDetail">
<span> <span>
<i class="fa-regular fa-eye"></i> 1 <i class="fa-regular fa-eye"></i> {{ views }}
</span> </span>
<span> <span>
<i class="fa-regular fa-thumbs-up"></i> 1 <i class="fa-regular fa-thumbs-up"></i> {{ likes }}
</span> </span>
<span> <span>
<i class="fa-regular fa-thumbs-down"></i> 1 <i class="fa-regular fa-thumbs-down"></i> {{ dislikes }}
</span> </span>
</template> </template>
</div> </div>
@ -29,14 +29,16 @@
</template> </template>
<template v-else> <template v-else>
<template v-if="author"> <template v-if="author">
<button class="btn author btn-label-primary btn-icon" @click="handleEdit"> <EditButton />
<DeleteButton />
<!-- <button class="btn author btn-label-primary btn-icon" @click="handleEdit">
<i class='bx bx-edit-alt'></i> <i class='bx bx-edit-alt'></i>
</button> </button>
<button class="btn author btn-label-primary btn-icon" @click="handleDelete"> <button class="btn author btn-label-primary btn-icon" @click="handleDelete">
<i class='bx bx-trash'></i> <i class='bx bx-trash'></i>
</button> </button> -->
</template> </template>
<BoardRecommendBtn :likeClicked="true" :dislikeClicked="false" /> <BoardRecommendBtn v-if="!isChild" :isRecommend="false" />
</template> </template>
</div> </div>
</div> </div>
@ -48,13 +50,12 @@ import axios from '@api';
import DeleteButton from '../button/DeleteBtn.vue'; import DeleteButton from '../button/DeleteBtn.vue';
import EditButton from '../button/EditBtn.vue'; import EditButton from '../button/EditBtn.vue';
import BoardRecommendBtn from '../button/BoardRecommendBtn.vue'; import BoardRecommendBtn from '../button/BoardRecommendBtn.vue';
import { onMounted } from 'vue';
// Vue Router // Vue Router
const router = useRouter(); const router = useRouter();
// Props // Props
defineProps({ const props = defineProps({
profileName: { profileName: {
type: String, type: String,
default: '익명', default: '익명',
@ -71,6 +72,22 @@ defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
views: {
type: Number,
default: 0,
},
likes: {
type: Number,
default: null,
},
dislikes: {
type: Number,
default: null,
},
isChild: {
type: Boolean,
default: false,
}
}); });
const boardId = 100; //!! const boardId = 100; //!!

View File

@ -1,14 +1,16 @@
<template> <template v-if="isRecommend">
<button class="btn btn-label-primary btn-icon" :class="likeClicked ? 'clicked' : '', bigBtn ? 'big' : '' "> <button class="btn btn-label-primary btn-icon" :class="{'clicked': likeClicked, 'big': bigBtn}" @click="handleLike">
<i class="fa-regular fa-thumbs-up"></i> <span class="num">1</span> <i class="fa-regular fa-thumbs-up"></i> <span class="num">{{ likeCount }}</span>
</button> </button>
<button class="btn btn-label-danger btn-icon" :class="dislikeClicked ? 'clicked' : '', bigBtn ? 'big' : '' "> <button class="btn btn-label-danger btn-icon" :class="{'clicked': dislikeClicked, 'big': bigBtn}" @click="handleDislike">
<i class="fa-regular fa-thumbs-down"></i> <span class="num">1</span> <i class="fa-regular fa-thumbs-down"></i> <span class="num">{{ dislikeCount }}</span>
</button> </button>
</template> </template>
<script setup> <script setup>
defineProps({ import { ref, watch } from 'vue';
const props = defineProps({
likeClicked : { likeClicked : {
type : Boolean, type : Boolean,
default : false, default : false,
@ -20,8 +22,69 @@ defineProps({
bigBtn : { bigBtn : {
type :Boolean, type :Boolean,
default : false, default : false,
} },
isRecommend: {
type:Boolean,
default:true,
},
boardId: {
type: Number,
required: true,
},
commentId: {
type: Number,
default: null,
},
likeCount: {
type: Number,
default: 0,
},
dislikeCount: {
type: Number,
default: 0,
},
}); });
const emit = defineEmits(['updateReaction']);
const likeClicked = ref(props.likeClicked);
const dislikeClicked = ref(props.dislikeClicked);
const likeCount = ref(props.likeCount);
const dislikeCount = ref(props.dislikeCount);
// likeCount dislikeCount
watch(() => props.likeCount, (newVal) => {
likeCount.value = newVal;
});
watch(() => props.dislikeCount, (newVal) => {
dislikeCount.value = newVal;
});
const handleLike = () => {
likeClicked.value = !likeClicked.value;
likeCount.value += likeClicked.value ? 1 : -1;
emit('updateReaction', { type: 'like', boardId: props.boardId, commentId: props.commentId });
if(likeClicked.value === true){
if(dislikeClicked.value === true) {
dislikeClicked.value = false;
dislikeCount.value += -1
}
}
};
const handleDislike = () => {
dislikeClicked.value = !dislikeClicked.value;
dislikeCount.value += dislikeClicked.value ? 1 : -1;
emit('updateReaction', { type: 'dislike', boardId: props.boardId, commentId: props.commentId });
if(dislikeClicked.value === true){
if(likeClicked.value === true) {
likeClicked.value = false;
likeCount.value += -1
}
}
};
</script> </script>
<style scoped> <style scoped>

View File

@ -10,14 +10,15 @@
multiple multiple
/> />
<div v-if="showError" class="text-danger mt-1"> <div v-if="showError" class="text-danger mt-1">
{{ errorMsg }} {{ errorMessage }}
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, watch } from 'vue'; import { ref ,computed} from 'vue';
import { fileMsg } from '@/common/msgEnum';
// Props // Props
const prop = defineProps({ const prop = defineProps({
@ -38,27 +39,43 @@ const prop = defineProps({
}, },
}); });
// Emits
const emits = defineEmits(['update:data', 'update:isValid']); const emits = defineEmits(['update:data', 'update:isValid']);
// Constants
const MAX_TOTAL_SIZE = 5 * 1024 * 1024; // 5MB const MAX_TOTAL_SIZE = 5 * 1024 * 1024; // 5MB
const errorMsg = ref('첨부파일의 총 용량이 5MB를 초과합니다.'); const MAX_FILE_COUNT = 5; //
const showError = ref(false); const ALLOWED_FILE_TYPES = ['image/jpeg', 'image/png', 'application/pdf']; //
const showError = ref(false);
const fileMsgKey = ref(''); //
// Change Handler
const changeHandler = (event) => { const changeHandler = (event) => {
const files = Array.from(event.target.files); const files = Array.from(event.target.files);
const totalSize = files.reduce((sum, file) => sum + file.size, 0); const totalSize = files.reduce((sum, file) => sum + file.size, 0);
const invalidFiles = files.filter(file => !ALLOWED_FILE_TYPES.includes(file.type));
//
if (totalSize > MAX_TOTAL_SIZE) { if (totalSize > MAX_TOTAL_SIZE) {
showError.value = true; // showError.value = true;
emits('update:data', []); // fileMsgKey.value = 'FileMaxSizeMsg';
emits('update:isValid', false); // emits('update:data', []);
emits('update:isValid', false);
} else if (files.length > MAX_FILE_COUNT) {
showError.value = true;
fileMsgKey.value = 'FileMaxLengthMsg';
emits('update:data', []);
emits('update:isValid', false);
} else if (invalidFiles.length > 0) {
showError.value = true;
fileMsgKey.value = 'FileNotTypeMsg';
emits('update:data', []);
emits('update:isValid', false);
} else { } else {
showError.value = false; // showError.value = false;
emits('update:data', files); // fileMsgKey.value = '';
emits('update:isValid', true); // emits('update:data', files);
emits('update:isValid', true);
} }
}; };
const errorMessage = computed(() => (fileMsg[fileMsgKey.value] || ''));
</script> </script>

View File

@ -1,141 +1,124 @@
<template> <template>
<div class="card mb-3 shadow-sm"> <div class="card mb-3 shadow-sm fixed-card" style="width: 18rem; height: 17rem">
<div class="row g-0"> <!-- 이미지가 있을 경우 card-img-top으로 표시 -->
<!-- 이미지 섹션 --> <img
<div v-if="img" class="col-md-2"> v-if="img"
<img :src="img"
:src="img" class="card-img-top"
alt="이미지" alt="이미지"
class="img-fluid rounded-start" style="object-fit: cover; height: 100px;"
style="object-fit: cover; height: 100%; width: 100%;" />
/> <div class="card-body">
</div> <!-- 카테고리 태그 (존재할 경우) -->
<!-- 게시물 내용 섹션 --> <h6 v-if="category" class="badge rounded-pill bg-primary text-white mb-2">
<div :class="contentColClass"> {{ category }}
<div class="card-body"> </h6>
<!-- 태그 --> <!-- 제목과 첨부파일 아이콘 -->
<h6 class="badge rounded-pill bg-primary text-white mb-2"> <h5 class="card-title">
{{ category }} {{ title }}
</h6> <span class="text-muted" v-if="attachment">
<!-- 제목 --> <i class="fa-solid fa-paperclip"></i>
<h5 class="card-title"> </span>
{{ title }} </h5>
<span class="text-muted me-3" v-if="attachment"> <!-- 본문 -->
<i class="fa-solid fa-paperclip"></i> <p class="card-text limit-two-lines">{{ content }}</p>
</span> <!-- 날짜 조회수, 좋아요, 댓글 -->
</h5> <p class="card-text">
<!-- 본문 --> <small class="text-muted">{{ formattedDate }}</small>
<div class="card-text str_wrap" v-html="$common.contentToHtml(content)"></div> <div class="d-flex mt-2 mt-sm-0">
<!-- 날짜 --> <span class="text-muted me-3">
<div class="d-flex justify-content-between"> <i class="fa-regular fa-eye"></i> {{ views || 0 }}
<small class="text-muted">{{ formattedDate }}</small> </span>
<!-- 조회수, 좋아요, 댓글 --> <span class="text-muted me-3" v-if="comments !== null">
<div> <i class="bx bx-comment"></i> {{ comments }}
<span class="text-muted me-3"> </span>
<i class="fa-regular fa-eye"></i> {{ views || 0 }} <span class="text-muted me-3" v-if="likes != null">
</span> <i class="bx bx-like"></i> {{ likes }}
<span class="text-muted me-3" v-if="likes != null"> </span>
<i class="bx bx-like"></i> {{ likes }} <span class="text-muted me-3" v-if="dislikes != null">
</span> <i class="bx bx-dislike"></i> {{ dislikes }}
<span class="text-muted" v-if="comments !== null"> </span>
<i class="bx bx-comment"></i> {{ comments }} </div>
</span> </p>
</div> </div>
</div>
</div>
</div>
</div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed } from 'vue'; import { computed } from 'vue';
import { defineProps } from 'vue'; import { defineProps } from 'vue';
// Props const props = defineProps({
const props = defineProps({
img: { img: {
type: String, type: String,
default: null, default: null,
}, },
category: { category: {
type: String, type: String,
required: false, required: false,
}, },
title: { title: {
type: String, type: String,
required: true, required: true,
}, },
content: { content: {
type: String, type: String,
required: true, required: true,
}, },
date: { date: {
type: String, type: String,
required: true, required: true,
}, },
views: { views: {
type: Number, type: Number,
default: 0, default: 0,
}, },
likes: { likes: {
type: Number, type: Number,
default: null, default: null,
},
dislikes: {
type: Number,
default: null,
}, },
comments: { comments: {
type: Number, type: Number,
default: null, default: null,
}, },
attachment: { attachment: {
type: Boolean, type: Boolean,
default: false, default: false,
} }
}); });
// computed const formattedDate = computed(() => {
const contentColClass = computed(() => { const dateObj = new Date(props.date);
return props.img ? 'col-md-10 col-12' : 'col-md-12'; return `${dateObj.getFullYear()}-${String(dateObj.getMonth() + 1).padStart(2, '0')}-${String(dateObj.getDate()).padStart(2, '0')} ${String(dateObj.getHours()).padStart(2, '0')}:${String(dateObj.getMinutes()).padStart(2, '0')}`;
}); });
</script>
// formattedDate computed <style scoped>
const formattedDate = computed(() => { .card {
const date = new Date(props.date);
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(
date.getDate()
).padStart(2, "0")} ${String(date.getHours()).padStart(2, "0")}:${String(
date.getMinutes()
).padStart(2, "0")}`;
});
</script>
<style>
/* 카드 스타일 */
.card {
border: 1px solid #e6e6e6; border: 1px solid #e6e6e6;
border-radius: 8px; border-radius: 8px;
transition: transform 0.2s ease-in-out; transition: transform 0.2s ease-in-out;
} }
.card:hover { .card:hover {
transform: scale(1.02); transform: scale(1.02);
} }
/* 텍스트 줄임 표시 */ /* card-img-top의 모서리 둥글게 처리 */
.str_wrap { .card-img-top {
overflow: hidden; border-top-left-radius: 8px;
text-overflow: ellipsis; border-top-right-radius: 8px;
display: -webkit-box; }
-webkit-box-orient: vertical;
}
/* 이미지 스타일 */ /* 내용 텍스트를 두 줄로 제한 */
.img-fluid { .limit-two-lines {
border-radius: 8px 0 0 8px; display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
} }
</style>
/* 태그 배지 스타일 */
.badge {
font-size: 0.8rem;
padding: 5px 10px;
}
</style>

View File

@ -1,9 +1,10 @@
<template> <template>
<div class="mt-4"> <div class="container my-4">
<div class="row">
<div v-if="posts.length === 0" class="text-center"> <div v-if="posts.length === 0" class="text-center">
<p class="text-muted mt-4">게시물이 없습니다.</p> <p class="text-muted mt-4">게시물이 없습니다.</p>
</div> </div>
<div v-for="post in posts" :key="post.id" @click="handleClick(post.id)"> <div class="col-md-3 mb-4 d-flex justify-content-center" v-for="post in posts" :key="post.id" @click="handleClick(post.id)">
<BoardCard <BoardCard
:img="post.img || null" :img="post.img || null"
:category="post.category || ''" :category="post.category || ''"
@ -11,11 +12,14 @@
:content="post.content" :content="post.content"
:date="post.date" :date="post.date"
:views="post.views || 0" :views="post.views || 0"
:likes="post.likes"
:dislikes="post.dislikes"
v-bind="getBoardCardProps(post)" v-bind="getBoardCardProps(post)"
:attachment="post.attachment || false" :attachment="post.attachment || false"
@click="() => goDetail(post.id)" @click="() => goDetail(post.id)"
/> />
</div> </div>
</div>
</div> </div>
</template> </template>

View File

@ -1,36 +1,19 @@
<template> <template>
<div class="container-xxl flex-grow-1 container-p-y"> <div class="container flex-grow-1 container-p-y">
<div class="row mb-4"> <div class="row mb-4">
<!-- 검색창 --> <!-- 검색창 -->
<div class="col"> <div class="container col-11 px-10 ">
<search-bar @update:data="search" /> <search-bar @update:data="search" />
</div> </div>
</div>
<div class="row">
<!-- 글쓰기 --> <!-- 글쓰기 -->
<div class="mb-4"> <div class="container col-1 px-12 py-2">
<router-link to="/board/write"> <router-link to="/board/write">
<WriteButton /> <WriteButton />
</router-link> </router-link>
</div> </div>
</div> </div>
<!-- 공지사항 리스트 --> <div class="row g-3">
<div v-if="pagination.currentPage === 1" class="mb-8">
<div class="row g-3">
<h3>공지사항</h3>
</div>
<div class="row">
<BoardCardList :posts="noticeList" @click="goDetail" />
</div>
</div>
<!-- 일반 리스트 -->
<div>
<div class="row g-3">
<h3 class="col">일반게시판</h3>
<!-- 셀렉트 박스 --> <!-- 셀렉트 박스 -->
<div class="col-12 col-md-auto"> <div class="col-12 col-md-auto">
<select class="form-select" v-model="selectedOrder" @change="handleSortChange"> <select class="form-select" v-model="selectedOrder" @change="handleSortChange">
@ -39,6 +22,16 @@
</select> </select>
</div> </div>
</div> </div>
<!-- 공지사항 리스트 -->
<div v-if="pagination.currentPage === 1" class="mb-8">
<div class="row">
<BoardCardList :posts="noticeList" @click="goDetail" />
</div>
</div>
<!-- 일반 리스트 -->
<div>
<div class="row"> <div class="row">
<BoardCardList :posts="generalList" @click="goDetail" /> <BoardCardList :posts="generalList" @click="goDetail" />
</div> </div>
@ -131,11 +124,13 @@ const fetchGeneralPosts = async (page = 1) => {
generalList.value = data.list.map((post, index) => ({ generalList.value = data.list.map((post, index) => ({
...post, ...post,
id: post.id || index, id: post.id || index,
img: post.img || null, img: post.firstImageUrl || null,
views: post.cnt || 0, views: post.cnt || 0,
likes: post.likeCount != null ? post.likeCount : null, likes: post.likeCount != null ? post.dislikeCount : null,
dislikes: post.dislikeCount != null ? post.likeCount : null,
comments: post.commentCount != null ? post.commentCount : null, comments: post.commentCount != null ? post.commentCount : null,
attachment: post.hasAttachment || false, attachment: post.hasAttachment || false,
content: post.plainContent || '',
})); }));
// //
@ -170,9 +165,10 @@ const fetchNoticePosts = async () => {
noticeList.value = response.data.data.map((post, index) => ({ noticeList.value = response.data.data.map((post, index) => ({
...post, ...post,
id: post.id || index, id: post.id || index,
img: post.img || null, img: post.firstImageUrl || null,
views: post.cnt || 0, views: post.cnt || 0,
attachment: post.hasAttachment || false, attachment: post.hasAttachment || false,
content: post.plainContent || '',
})); }));
} else { } else {
console.error("데이터 오류:", response.data); console.error("데이터 오류:", response.data);

View File

@ -5,29 +5,75 @@
<div class="card"> <div class="card">
<!-- 프로필 헤더 --> <!-- 프로필 헤더 -->
<div class="card-header"> <div class="card-header">
<BoardProfile :boardId="currentBoardId" :profileName="profileName" /> <BoardProfile
:boardId="currentBoardId"
:profileName="profileName"
:views="views"
:likes="likes"
:dislikes="dislikes"
class="pb-6 border-bottom"
/>
</div> </div>
<!-- 게시글 내용 --> <!-- 게시글 내용 -->
<div class="card-body"> <div class="card-body">
<h5 class="mb-4">{{ boardTitle }}</h5>
<!-- HTML 콘텐츠 렌더링 -->
<div class="board-content text-body" style="line-height: 1.6;" v-html="$common.contentToHtml(boardContent)">
<div class="d-flex justify-content-between align-items-center flex-wrap mb-6 gap-2">
<!-- 제목 섹션 -->
<div class="me-1">
<h5 class="mb-4">{{ boardTitle }}</h5>
</div>
<!-- 첨부파일 섹션 -->
<div v-if="attachments" class="btn-group">
<button type="button" class="btn btn-label-secondary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-download me-2"></i>
첨부파일
<!-- (<span class="attachment-num">{{ dropdownItems.length }}</span>) -->
</button>
<!-- <ul class="dropdown-menu">
<li v-for="(item, index) in dropdownItems" :key="index">
<a class="dropdown-item" href="javascript:void(0);">
{{ item.label }}
</a>
</li>
</ul> -->
</div>
</div> </div>
<!-- HTML 콘텐츠 렌더링 -->
<div class="board-content text-body" style="line-height: 1.6;" v-html="$common.contentToHtml(boardContent)"></div>
<!-- 좋아요 버튼 -->
<div class="row justify-content-center my-10">
<BoardRecommendBtn
:bigBtn="true"
:boardId="currentBoardId"
:commentId="null"
:likeCount="currentLikeCount"
:dislikeCount="currentDislikeCount"
@updateReaction="handleUpdateReaction"
/>
</div>
<!-- 첨부파일 목록 --> <!-- 첨부파일 목록 -->
<ul v-if="attachments.length" class="attachments mt-4 list-unstyled"> <!-- <ul v-if="attachments.length" class="attachments mt-4 list-unstyled">
<li v-for="(attachment, index) in attachments" :key="index" class="mb-2"> <li v-for="(attachment, index) in attachments" :key="index" class="mb-2">
<a :href="attachment.url" target="_blank" class="text-decoration-none">{{ attachment.name }}</a> <a :href="attachment.url" target="_blank" class="text-decoration-none">{{ attachment.name }}</a>
</li> </li>
</ul> </ul> -->
<!-- 댓글 영역 --> <!-- 댓글 영역 -->
<BoardComentArea :comments="comments" /> <BoardComentArea :comments="comments" />
</div>
<!-- 수정 버튼 --> <!-- 수정 버튼 -->
<div class="card-footer d-flex justify-content-end"> <!-- <button class="btn btn-primary" @click="goToEditPage">
<button class="btn btn-primary" @click="goToEditPage">
수정 수정
</button> </button> -->
</div>
<div class="card-footer">
<BoardCommentList/>
<Pagination/>
</div> </div>
</div> </div>
</div> </div>
@ -38,18 +84,23 @@
<script setup> <script setup>
import BoardComentArea from '@c/board/BoardComentArea.vue'; import BoardComentArea from '@c/board/BoardComentArea.vue';
import BoardProfile from '@c/board/BoardProfile.vue'; import BoardProfile from '@c/board/BoardProfile.vue';
import BoardCommentList from '@/components/board/BoardCommentList.vue';
import BoardRecommendBtn from '@/components/button/BoardRecommendBtn.vue';
import Pagination from '@/components/pagination/Pagination.vue';
import { ref, onMounted } from 'vue'; import { ref, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import axios from '@api'; import axios from '@api';
import Quill from 'quill';
// //
const profileName = ref('익명 사용자'); const profileName = ref('익명 사용자');
const boardTitle = ref('제목 없음'); const boardTitle = ref('제목 없음');
const boardContent = ref(''); const boardContent = ref('');
const convertedContent = ref('내용 없음');
const comments = ref([]); const comments = ref([]);
const attachments = ref([]); const attachments = ref([]);
const views = ref(0);
const likes = ref(0);
const dislikes = ref(0);
const attachment = ref(false);
// ID // ID
const route = useRoute(); const route = useRoute();
@ -69,19 +120,37 @@ const fetchBoardDetails = async () => {
// API // API
const boardDetail = data.boardDetail || {}; const boardDetail = data.boardDetail || {};
profileName.value = boardDetail.author || '익명 사용자'; // console.log('boardDetail:', boardDetail);
boardTitle.value = boardDetail.title || '제목 없음';
boardContent.value = boardDetail.content || '';
profileName.value = data.author || '익명 사용자';
boardTitle.value = data.title || '제목 없음';
boardContent.value = data.content || '';
views.value = data.cnt || 0;
likes.value = data.likeCount || 0;
dislikes.value = data.dislikeCount || 0;
attachment.value = data.hasAttachment || null;
attachments.value = data.attachments || []; attachments.value = data.attachments || [];
comments.value = data.comments || []; comments.value = data.comments || [];
} catch (error) { } catch (error) {
console.error('게시물 가져오기 오류:', error);
alert('게시물 데이터를 불러오는 중 오류가 발생했습니다.'); alert('게시물 데이터를 불러오는 중 오류가 발생했습니다.');
} }
}; };
const currentLikeCount = ref(10);
const currentDislikeCount = ref(2);
// ,
const handleUpdateReaction = async ({ type, boardId, commentId }) => {
try {
const cmtId = commentId !== null ? commentId : 0;
const response = await axios.post(`/board/${boardId}/${cmtId}/reaction`, { type });
} catch (error) {
alert('반응을 업데이트하는 중 오류 발생');
}
};
// //
onMounted(() => { onMounted(() => {
fetchBoardDetails(); fetchBoardDetails();

View File

@ -22,7 +22,7 @@
<label class="col-md-2 col-form-label">카테고리 <span class="text-danger">*</span></label> <label class="col-md-2 col-form-label">카테고리 <span class="text-danger">*</span></label>
<div class="d-flex flex-wrap align-items-center mt-3 ms-1"> <div class="d-flex flex-wrap align-items-center mt-3 ms-1">
<div <div
v-for="(categoryName, index) in categoryList" v-for="(category, index) in categoryList"
:key="index" :key="index"
class="form-check me-3" class="form-check me-3"
> >
@ -30,18 +30,19 @@
class="form-check-input" class="form-check-input"
type="radio" type="radio"
:id="`category-${index}`" :id="`category-${index}`"
:value="index" :value="category.CMNCODVAL"
v-model="category" v-model="categoryValue"
/> />
<label class="form-check-label" :for="`category-${index}`"> <label class="form-check-label" :for="`category-${index}`">
{{ categoryName }} {{ category.CMNCODNAM }}
</label> </label>
</div> </div>
</div> </div>
<div class="invalid-feedback" :class="categoryAlert ? 'display-block' : ''">카테고리를 선택해주세요.</div>
</div> </div>
<!-- 비밀번호 필드 --> <!-- 비밀번호 필드 -->
<div v-if="category === 1" class="mb-4"> <div v-if="categoryValue === 300102" class="mb-4">
<FormInput <FormInput
title="비밀번호" title="비밀번호"
name="pw" name="pw"
@ -94,25 +95,39 @@
import QEditor from '@c/editor/QEditor.vue'; import QEditor from '@c/editor/QEditor.vue';
import FormInput from '@c/input/FormInput.vue'; import FormInput from '@c/input/FormInput.vue';
import FormFile from '@c/input/FormFile.vue'; import FormFile from '@c/input/FormFile.vue';
import { getCurrentInstance, ref } from 'vue'; import { getCurrentInstance, ref, onMounted } from 'vue';
import router from '@/router'; import router from '@/router';
import axios from '@api'; import axios from '@api';
const categoryList = ['자유', '익명', '공지사항']; // const categoryList = ref([]);
const title = ref(''); const title = ref('');
const password = ref(''); const password = ref('');
const category = ref(0); // 0 const categoryValue = ref(null);
const content = ref(''); const content = ref('');
const attachFiles = ref(null); const attachFiles = ref(null);
const isFileValid = ref(true); // const isFileValid = ref(true);
const titleAlert = ref(false); const titleAlert = ref(false);
const passwordAlert = ref(false); const passwordAlert = ref(false);
const contentAlert = ref(false); const contentAlert = ref(false);
const categoryAlert = ref(false);
const attachFilesAlert = ref(false); const attachFilesAlert = ref(false);
const { appContext } = getCurrentInstance(); const { appContext } = getCurrentInstance();
const $common = appContext.config.globalProperties.$common; // $common const $common = appContext.config.globalProperties.$common;
const fetchCategories = async () => {
try {
const response = await axios.get('board/categories');
categoryList.value = response.data.data;
} catch (error) {
console.error('카테고리 불러오기 오류:', error);
}
};
onMounted(() => {
fetchCategories();
});
const goList = () => { const goList = () => {
router.push('/board'); router.push('/board');
@ -120,10 +135,11 @@ const goList = () => {
const write = async () => { const write = async () => {
titleAlert.value = !title.value; titleAlert.value = !title.value;
passwordAlert.value = category.value === 1 && !password.value; passwordAlert.value = categoryValue.value === 300102 && !password.value;
contentAlert.value = !content.value; contentAlert.value = !content.value;
categoryAlert.value = !categoryValue.value;
if (titleAlert.value || passwordAlert.value || contentAlert.value || !isFileValid.value) { if (titleAlert.value || passwordAlert.value || contentAlert.value || categoryAlert.value || !isFileValid.value) {
return; return;
} }
@ -131,8 +147,8 @@ const write = async () => {
const boardData = { const boardData = {
LOCBRDTTL: title.value, LOCBRDTTL: title.value,
LOCBRDCON: $common.deltaAsJson(content.value), LOCBRDCON: $common.deltaAsJson(content.value),
LOCBRDPWD: category.value === 1 ? password.value : null, LOCBRDPWD: categoryValue.value === 300102 ? password.value : null,
LOCBRDTYP: category.value === 1 ? 'S' : 'F', LOCBRDTYP: categoryValue.value
}; };
const { data: boardResponse } = await axios.post('board', boardData); const { data: boardResponse } = await axios.post('board', boardData);