Files
sb-front/src/views/board/BoardWriteView.vue
T

230 lines
6.0 KiB
Vue
Raw Normal View History

<script setup>
import { onMounted, ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { boardApi } from '@/api/boardApi'
import { useAuthStore } from '@/stores/auth'
import MarkdownEditor from '@/components/editor/MarkdownEditor.vue'
import IconBtn from '@/components/ui/IconBtn.vue'
import { DEFAULT_BOARD } from '@/constants/boards'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const category = route.params.category || DEFAULT_BOARD
const editId = route.params.id || null
const isEdit = computed(() => !!editId)
// 공지: 관리자가 커뮤니티 게시판 글을 작성/수정할 때 체크박스로 설정
const isAdmin = computed(() => auth.user?.role === 'ADMIN')
const canNotice = computed(() => isAdmin.value && category === 'community')
const title = ref('')
const content = ref('')
const notice = ref(false)
const tagGroups = ref([])
const selectedTagIds = ref([])
const loading = ref(false)
const error = ref(null)
// 카테고리 구분 없이 태그만 평탄화
const allTags = computed(() => tagGroups.value.flatMap((g) => g.tags))
function toggleTag(id) {
const i = selectedTagIds.value.indexOf(id)
if (i >= 0) selectedTagIds.value.splice(i, 1)
else selectedTagIds.value.push(id)
}
async function loadTagGroups() {
try {
tagGroups.value = await boardApi.tagGroups()
} catch {
tagGroups.value = []
}
}
async function loadForEdit() {
loading.value = true
try {
const p = await boardApi.get(editId)
title.value = p.title
content.value = p.content
notice.value = !!p.notice
// 게시글의 태그(이름) → 현재 태그 목록에서 id 매핑
const nameToId = {}
tagGroups.value.forEach((c) => c.tags.forEach((t) => (nameToId[t.name] = t.id)))
selectedTagIds.value = (p.tags || []).map((n) => nameToId[n]).filter(Boolean)
} catch (e) {
error.value = e.response?.data?.message || '게시글을 불러오지 못했습니다.'
} finally {
loading.value = false
}
}
async function submit() {
error.value = null
if (!title.value.trim() || !content.value.trim()) {
error.value = '제목과 내용을 입력하세요.'
return
}
loading.value = true
const payload = {
title: title.value.trim(),
content: content.value,
category,
notice: canNotice.value ? notice.value : false,
tagIds: selectedTagIds.value,
}
try {
const saved = isEdit.value
? await boardApi.update(editId, payload)
: await boardApi.create(payload)
router.replace(`/board/${category}/${saved.id}`)
} catch (e) {
error.value = e.response?.data?.message || '저장에 실패했습니다.'
} finally {
loading.value = false
}
}
function cancel() {
if (isEdit.value) router.replace(`/board/${category}/${editId}`)
else router.replace(`/board/${category}`)
}
onMounted(async () => {
await loadTagGroups()
if (isEdit.value) await loadForEdit()
})
</script>
<template>
<section class="write">
<form class="write-form" @submit.prevent="submit">
<input v-model="title" type="text" placeholder="제목" maxlength="200" :disabled="loading" />
<!-- 공지 등록 (관리자, 커뮤니티 ) -->
<label v-if="canNotice" class="notice-check">
<input v-model="notice" type="checkbox" :disabled="loading" />
📌 공지로 등록 (목록 최상단 고정)
</label>
<!-- 태그 선택 (DB 등록 태그를 뱃지로 토글) -->
<div class="tag-select">
<div v-if="!allTags.length" class="hint">
등록된 태그가 없습니다. (관리자가 [태그 관리]에서 추가할 있습니다)
</div>
<button
v-for="t in allTags"
:key="t.id"
type="button"
class="tag-badge"
:class="{ active: selectedTagIds.includes(t.id) }"
@click="toggleTag(t.id)"
>{{ t.name }}</button>
</div>
<MarkdownEditor v-model="content" height="420px" placeholder="내용을 입력하세요" />
<p v-if="error" class="msg error">{{ error }}</p>
<div class="buttons">
<IconBtn icon="close" title="취소" @click="cancel" />
<IconBtn icon="save" :title="isEdit ? '수정' : '등록'" variant="primary" type="submit" :disabled="loading" />
</div>
</form>
</section>
</template>
<style scoped>
.write {
max-width: 800px;
margin: 0 auto;
}
h1 {
font-size: 1.5rem;
margin-bottom: 1rem;
}
.write-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.write-form input[type='text'] {
padding: 0.6rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
font-family: inherit;
}
.tag-select {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
border: 1px solid var(--color-border);
border-radius: 4px;
padding: 0.75rem;
background: var(--color-background-soft);
}
.tag-badge {
padding: 0.3rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: 999px;
background: var(--color-background);
color: var(--color-text);
font-size: 0.85rem;
cursor: pointer;
transition: all 0.15s;
}
.tag-badge:hover {
border-color: var(--color-border-hover);
}
.tag-badge.active {
border-color: hsla(160, 100%, 37%, 1);
background: hsla(160, 100%, 37%, 0.12);
color: hsla(160, 100%, 37%, 1);
font-weight: 600;
}
.hint {
font-size: 0.85rem;
opacity: 0.65;
}
.notice-check {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.9rem;
cursor: pointer;
}
.notice-check input {
width: auto;
cursor: pointer;
}
.buttons {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
button {
padding: 0.5rem 1.2rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
cursor: pointer;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button.primary {
border-color: hsla(160, 100%, 37%, 1);
color: hsla(160, 100%, 37%, 1);
}
.msg.error {
color: #c0392b;
}
</style>