memi-board 0.3.1 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -28
- package/dist/composables/useMemiBoardSettings.d.ts +10 -4
- package/dist/index.d.ts +3 -9
- package/dist/index.js +216 -828
- package/dist/index.js.map +1 -1
- package/dist/module.d.ts +4 -0
- package/dist/module.mjs +75 -0
- package/dist/module.mjs.map +1 -0
- package/dist/runtime/components/Attachments.vue +173 -0
- package/dist/runtime/components/CommentForm.vue +83 -0
- package/dist/runtime/components/CommentList.vue +64 -0
- package/dist/runtime/components/Detail.vue +156 -0
- package/dist/runtime/components/Editor.vue +287 -0
- package/dist/runtime/components/List.vue +134 -0
- package/dist/runtime/components/SignIn.vue +146 -0
- package/dist/runtime/components/VersionHistory.vue +36 -0
- package/package.json +35 -9
- package/dist/components/Attachments.vue.d.ts +0 -17
- package/dist/components/CommentForm.vue.d.ts +0 -5
- package/dist/components/CommentList.vue.d.ts +0 -5
- package/dist/components/Detail.vue.d.ts +0 -11
- package/dist/components/Editor.vue.d.ts +0 -12
- package/dist/components/List.vue.d.ts +0 -14
- package/dist/components/SignIn.vue.d.ts +0 -2
- package/dist/components/VersionHistory.vue.d.ts +0 -2
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import type { Attachment } from 'memi-board'
|
|
4
|
+
import { useMemiBoardStorage } from 'memi-board'
|
|
5
|
+
|
|
6
|
+
const props = withDefaults(defineProps<{
|
|
7
|
+
modelValue: Attachment[]
|
|
8
|
+
/** 새 글 작성 중이면 아직 slug가 없으므로 임시 네임스페이스를 넘긴다. */
|
|
9
|
+
postId: string
|
|
10
|
+
editable?: boolean
|
|
11
|
+
maxFiles?: number
|
|
12
|
+
}>(), {
|
|
13
|
+
editable: false,
|
|
14
|
+
maxFiles: 5,
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
const emit = defineEmits<{ 'update:modelValue': [attachments: Attachment[]] }>()
|
|
18
|
+
|
|
19
|
+
const { uploadAttachment, deleteAttachment } = useMemiBoardStorage()
|
|
20
|
+
|
|
21
|
+
const fileInput = ref<HTMLInputElement | null>(null)
|
|
22
|
+
const uploading = ref(false)
|
|
23
|
+
const uploadProgress = ref(0)
|
|
24
|
+
const deletingIndex = ref<number | null>(null)
|
|
25
|
+
const uploadError = ref('')
|
|
26
|
+
|
|
27
|
+
function isImage(type: string) {
|
|
28
|
+
return type.startsWith('image/')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function onFileInputChange(e: Event) {
|
|
32
|
+
const input = e.target as HTMLInputElement
|
|
33
|
+
const files = Array.from(input.files ?? [])
|
|
34
|
+
input.value = ''
|
|
35
|
+
if (!files.length) return
|
|
36
|
+
|
|
37
|
+
const remaining = props.maxFiles - props.modelValue.length
|
|
38
|
+
const toUpload = files.slice(0, remaining)
|
|
39
|
+
|
|
40
|
+
uploading.value = true
|
|
41
|
+
uploadError.value = ''
|
|
42
|
+
try {
|
|
43
|
+
const uploaded: Attachment[] = []
|
|
44
|
+
for (const file of toUpload) {
|
|
45
|
+
uploadProgress.value = 0
|
|
46
|
+
const { promise } = uploadAttachment(file, props.postId, (ratio) => {
|
|
47
|
+
uploadProgress.value = ratio
|
|
48
|
+
})
|
|
49
|
+
uploaded.push(await promise)
|
|
50
|
+
}
|
|
51
|
+
emit('update:modelValue', [...props.modelValue, ...uploaded])
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
uploadError.value = (e as Error).message
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
uploading.value = false
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function removeAttachment(index: number) {
|
|
62
|
+
const attachment = props.modelValue[index]
|
|
63
|
+
if (!attachment) return
|
|
64
|
+
deletingIndex.value = index
|
|
65
|
+
try {
|
|
66
|
+
await deleteAttachment(attachment)
|
|
67
|
+
emit('update:modelValue', props.modelValue.filter((_, i) => i !== index))
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
deletingIndex.value = null
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function fileIcon(type: string): string {
|
|
75
|
+
if (type === 'application/pdf') return 'i-lucide-file-text'
|
|
76
|
+
if (type.includes('zip') || type.includes('archive') || type.includes('compressed')) return 'i-lucide-archive'
|
|
77
|
+
if (type.includes('spreadsheet') || type.includes('excel') || type.includes('csv')) return 'i-lucide-table'
|
|
78
|
+
if (type.includes('presentation') || type.includes('powerpoint')) return 'i-lucide-presentation'
|
|
79
|
+
if (isImage(type)) return 'i-lucide-image'
|
|
80
|
+
if (type.includes('video')) return 'i-lucide-video'
|
|
81
|
+
if (type.includes('audio')) return 'i-lucide-music'
|
|
82
|
+
return 'i-lucide-file'
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function formatSize(bytes: number): string {
|
|
86
|
+
if (bytes < 1024) return `${bytes} B`
|
|
87
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
88
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
|
89
|
+
}
|
|
90
|
+
</script>
|
|
91
|
+
|
|
92
|
+
<template>
|
|
93
|
+
<div class="space-y-2">
|
|
94
|
+
<div
|
|
95
|
+
v-for="(attachment, i) in modelValue"
|
|
96
|
+
:key="attachment.path"
|
|
97
|
+
class="flex items-center gap-3 px-3 py-2 rounded-lg border border-default"
|
|
98
|
+
>
|
|
99
|
+
<img
|
|
100
|
+
v-if="isImage(attachment.type)"
|
|
101
|
+
:src="attachment.url"
|
|
102
|
+
class="size-10 rounded object-cover shrink-0"
|
|
103
|
+
>
|
|
104
|
+
<UIcon
|
|
105
|
+
v-else
|
|
106
|
+
:name="fileIcon(attachment.type)"
|
|
107
|
+
class="size-5 shrink-0 text-muted"
|
|
108
|
+
/>
|
|
109
|
+
<div class="flex-1 min-w-0">
|
|
110
|
+
<p class="text-sm font-medium truncate">
|
|
111
|
+
{{ attachment.name }}
|
|
112
|
+
</p>
|
|
113
|
+
<p class="text-xs text-muted">
|
|
114
|
+
{{ formatSize(attachment.size) }}
|
|
115
|
+
</p>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<UButton
|
|
119
|
+
v-if="editable"
|
|
120
|
+
icon="i-lucide-x"
|
|
121
|
+
size="xs"
|
|
122
|
+
color="error"
|
|
123
|
+
variant="ghost"
|
|
124
|
+
:loading="deletingIndex === i"
|
|
125
|
+
@click="removeAttachment(i)"
|
|
126
|
+
/>
|
|
127
|
+
<UButton
|
|
128
|
+
v-else
|
|
129
|
+
icon="i-lucide-download"
|
|
130
|
+
size="xs"
|
|
131
|
+
color="neutral"
|
|
132
|
+
variant="ghost"
|
|
133
|
+
:to="attachment.url"
|
|
134
|
+
target="_blank"
|
|
135
|
+
/>
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
<template v-if="editable && modelValue.length < maxFiles">
|
|
139
|
+
<input
|
|
140
|
+
ref="fileInput"
|
|
141
|
+
type="file"
|
|
142
|
+
multiple
|
|
143
|
+
class="hidden"
|
|
144
|
+
@change="onFileInputChange"
|
|
145
|
+
>
|
|
146
|
+
<UButton
|
|
147
|
+
icon="i-lucide-paperclip"
|
|
148
|
+
:label="`파일 첨부 (${modelValue.length} / ${maxFiles})`"
|
|
149
|
+
variant="outline"
|
|
150
|
+
color="neutral"
|
|
151
|
+
size="sm"
|
|
152
|
+
:loading="uploading"
|
|
153
|
+
@click="fileInput?.click()"
|
|
154
|
+
/>
|
|
155
|
+
<UProgress
|
|
156
|
+
v-if="uploading"
|
|
157
|
+
:model-value="Math.round(uploadProgress * 100)"
|
|
158
|
+
/>
|
|
159
|
+
<p
|
|
160
|
+
v-if="uploadError"
|
|
161
|
+
class="text-xs text-error"
|
|
162
|
+
>
|
|
163
|
+
{{ uploadError }}
|
|
164
|
+
</p>
|
|
165
|
+
</template>
|
|
166
|
+
<p
|
|
167
|
+
v-else-if="editable"
|
|
168
|
+
class="text-xs text-muted"
|
|
169
|
+
>
|
|
170
|
+
최대 {{ maxFiles }}개까지 첨부할 수 있습니다.
|
|
171
|
+
</p>
|
|
172
|
+
</div>
|
|
173
|
+
</template>
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import { useMemiBoardAuth } from 'memi-board'
|
|
4
|
+
import { useMemiBoardComments } from 'memi-board'
|
|
5
|
+
import { useMemiBoardModeration } from 'memi-board'
|
|
6
|
+
|
|
7
|
+
const props = defineProps<{ postId: string }>()
|
|
8
|
+
|
|
9
|
+
const { user, isSignedIn } = useMemiBoardAuth()
|
|
10
|
+
const { addComment } = useMemiBoardComments(props.postId)
|
|
11
|
+
const { checkText } = useMemiBoardModeration()
|
|
12
|
+
|
|
13
|
+
const body = ref('')
|
|
14
|
+
const submitting = ref(false)
|
|
15
|
+
const error = ref('')
|
|
16
|
+
|
|
17
|
+
async function handleSubmit() {
|
|
18
|
+
error.value = ''
|
|
19
|
+
if (!body.value.trim()) return
|
|
20
|
+
if (!user.value) {
|
|
21
|
+
error.value = '로그인이 필요합니다.'
|
|
22
|
+
return
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
submitting.value = true
|
|
26
|
+
try {
|
|
27
|
+
const moderation = await checkText(body.value)
|
|
28
|
+
if (moderation.flagged) {
|
|
29
|
+
error.value = moderation.reason || '작성할 수 없는 내용이 포함되어 있습니다.'
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
await addComment({
|
|
33
|
+
body: body.value,
|
|
34
|
+
authorUid: user.value.uid,
|
|
35
|
+
authorName: user.value.displayName,
|
|
36
|
+
authorPhoto: user.value.photoURL,
|
|
37
|
+
})
|
|
38
|
+
body.value = ''
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
error.value = (e as Error).message
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
submitting.value = false
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
</script>
|
|
48
|
+
|
|
49
|
+
<template>
|
|
50
|
+
<form
|
|
51
|
+
v-if="isSignedIn"
|
|
52
|
+
class="flex flex-col gap-2"
|
|
53
|
+
@submit.prevent="handleSubmit"
|
|
54
|
+
>
|
|
55
|
+
<UTextarea
|
|
56
|
+
v-model="body"
|
|
57
|
+
placeholder="댓글을 입력하세요"
|
|
58
|
+
:rows="2"
|
|
59
|
+
/>
|
|
60
|
+
<div class="flex justify-between items-center">
|
|
61
|
+
<p
|
|
62
|
+
v-if="error"
|
|
63
|
+
class="text-xs text-error"
|
|
64
|
+
>
|
|
65
|
+
{{ error }}
|
|
66
|
+
</p>
|
|
67
|
+
<div class="flex-1" />
|
|
68
|
+
<UButton
|
|
69
|
+
type="submit"
|
|
70
|
+
size="sm"
|
|
71
|
+
label="댓글 작성"
|
|
72
|
+
:loading="submitting"
|
|
73
|
+
:disabled="!body.trim()"
|
|
74
|
+
/>
|
|
75
|
+
</div>
|
|
76
|
+
</form>
|
|
77
|
+
<p
|
|
78
|
+
v-else
|
|
79
|
+
class="text-sm text-muted"
|
|
80
|
+
>
|
|
81
|
+
댓글을 작성하려면 로그인이 필요합니다.
|
|
82
|
+
</p>
|
|
83
|
+
</template>
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import { formatDate } from 'memi-board'
|
|
4
|
+
import { useMemiBoardComments } from 'memi-board'
|
|
5
|
+
import { useMemiBoardAuth } from 'memi-board'
|
|
6
|
+
|
|
7
|
+
const props = defineProps<{ postId: string }>()
|
|
8
|
+
|
|
9
|
+
const { comments, deleteComment } = useMemiBoardComments(props.postId)
|
|
10
|
+
const { canDeleteComment } = useMemiBoardAuth()
|
|
11
|
+
|
|
12
|
+
const deletingId = ref<string | null>(null)
|
|
13
|
+
|
|
14
|
+
async function handleDelete(commentId: string) {
|
|
15
|
+
deletingId.value = commentId
|
|
16
|
+
try {
|
|
17
|
+
await deleteComment(commentId)
|
|
18
|
+
}
|
|
19
|
+
finally {
|
|
20
|
+
deletingId.value = null
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<template>
|
|
26
|
+
<div class="flex flex-col gap-3">
|
|
27
|
+
<p
|
|
28
|
+
v-if="!comments.length"
|
|
29
|
+
class="text-sm text-muted"
|
|
30
|
+
>
|
|
31
|
+
아직 댓글이 없습니다.
|
|
32
|
+
</p>
|
|
33
|
+
|
|
34
|
+
<div
|
|
35
|
+
v-for="comment in comments"
|
|
36
|
+
:key="comment.id"
|
|
37
|
+
class="flex items-start gap-3"
|
|
38
|
+
>
|
|
39
|
+
<UAvatar
|
|
40
|
+
:src="comment.authorPhoto ?? undefined"
|
|
41
|
+
:alt="comment.authorName ?? '익명'"
|
|
42
|
+
size="sm"
|
|
43
|
+
/>
|
|
44
|
+
<div class="flex-1 min-w-0">
|
|
45
|
+
<div class="flex items-center gap-2">
|
|
46
|
+
<span class="text-sm font-medium">{{ comment.authorName ?? '익명' }}</span>
|
|
47
|
+
<span class="text-xs text-muted">{{ formatDate(comment.createdAt) }}</span>
|
|
48
|
+
</div>
|
|
49
|
+
<p class="text-sm whitespace-pre-wrap break-words">
|
|
50
|
+
{{ comment.body }}
|
|
51
|
+
</p>
|
|
52
|
+
</div>
|
|
53
|
+
<UButton
|
|
54
|
+
v-if="canDeleteComment(comment)"
|
|
55
|
+
icon="i-lucide-trash-2"
|
|
56
|
+
size="xs"
|
|
57
|
+
variant="ghost"
|
|
58
|
+
color="error"
|
|
59
|
+
:loading="deletingId === comment.id"
|
|
60
|
+
@click="handleDelete(comment.id!)"
|
|
61
|
+
/>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
</template>
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, onMounted, watch } from 'vue'
|
|
3
|
+
import type { PostDetail } from 'memi-board'
|
|
4
|
+
import { formatDate } from 'memi-board'
|
|
5
|
+
import { useMemiBoardPosts } from 'memi-board'
|
|
6
|
+
import { useMemiBoardAuth } from 'memi-board'
|
|
7
|
+
import { useMemiBoardSettings } from 'memi-board'
|
|
8
|
+
import { useMemiBoardComments } from 'memi-board'
|
|
9
|
+
import MemiBoardAttachments from './Attachments.vue'
|
|
10
|
+
import MemiBoardCommentForm from './CommentForm.vue'
|
|
11
|
+
import MemiBoardCommentList from './CommentList.vue'
|
|
12
|
+
|
|
13
|
+
const props = defineProps<{ postId: string }>()
|
|
14
|
+
|
|
15
|
+
const emit = defineEmits<{ deleted: [], edit: [postId: string] }>()
|
|
16
|
+
|
|
17
|
+
const { getPost, deletePost } = useMemiBoardPosts()
|
|
18
|
+
const { canEdit, canDelete } = useMemiBoardAuth()
|
|
19
|
+
const { categoryLabel } = useMemiBoardSettings()
|
|
20
|
+
// post.commentCount는 상세 진입 시점의 스냅샷이라 새 댓글이 즉시 반영되지 않는다 — 실시간 댓글 목록 길이로 표시한다.
|
|
21
|
+
const { comments } = useMemiBoardComments(props.postId)
|
|
22
|
+
|
|
23
|
+
const post = ref<PostDetail | null>(null)
|
|
24
|
+
const loading = ref(true)
|
|
25
|
+
const deleting = ref(false)
|
|
26
|
+
const notFound = ref(false)
|
|
27
|
+
|
|
28
|
+
async function load() {
|
|
29
|
+
loading.value = true
|
|
30
|
+
notFound.value = false
|
|
31
|
+
const result = await getPost(props.postId)
|
|
32
|
+
if (!result) {
|
|
33
|
+
notFound.value = true
|
|
34
|
+
}
|
|
35
|
+
post.value = result
|
|
36
|
+
loading.value = false
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
onMounted(load)
|
|
40
|
+
watch(() => props.postId, load)
|
|
41
|
+
|
|
42
|
+
async function handleDelete() {
|
|
43
|
+
if (!post.value) return
|
|
44
|
+
if (!window.confirm('이 게시글을 삭제하시겠습니까? 되돌릴 수 없습니다.')) return
|
|
45
|
+
deleting.value = true
|
|
46
|
+
try {
|
|
47
|
+
await deletePost(props.postId)
|
|
48
|
+
emit('deleted')
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
deleting.value = false
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
</script>
|
|
55
|
+
|
|
56
|
+
<template>
|
|
57
|
+
<div
|
|
58
|
+
v-if="loading"
|
|
59
|
+
class="flex flex-col gap-3"
|
|
60
|
+
>
|
|
61
|
+
<USkeleton class="h-8 w-2/3" />
|
|
62
|
+
<USkeleton class="h-32 w-full" />
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<p
|
|
66
|
+
v-else-if="notFound"
|
|
67
|
+
class="text-sm text-muted"
|
|
68
|
+
>
|
|
69
|
+
게시글을 찾을 수 없습니다.
|
|
70
|
+
</p>
|
|
71
|
+
|
|
72
|
+
<div
|
|
73
|
+
v-else-if="post"
|
|
74
|
+
class="flex flex-col gap-6"
|
|
75
|
+
>
|
|
76
|
+
<header class="flex flex-col gap-2">
|
|
77
|
+
<div class="flex items-start justify-between gap-4">
|
|
78
|
+
<div class="flex items-center gap-2 min-w-0">
|
|
79
|
+
<UBadge
|
|
80
|
+
v-if="post.category"
|
|
81
|
+
:label="categoryLabel(post.category)"
|
|
82
|
+
variant="subtle"
|
|
83
|
+
/>
|
|
84
|
+
<h1 class="text-2xl font-semibold">
|
|
85
|
+
{{ post.title }}
|
|
86
|
+
</h1>
|
|
87
|
+
</div>
|
|
88
|
+
<div
|
|
89
|
+
v-if="canEdit(post) || canDelete(post)"
|
|
90
|
+
class="flex gap-2 shrink-0"
|
|
91
|
+
>
|
|
92
|
+
<UButton
|
|
93
|
+
v-if="canEdit(post)"
|
|
94
|
+
icon="i-lucide-pencil"
|
|
95
|
+
size="sm"
|
|
96
|
+
variant="outline"
|
|
97
|
+
color="neutral"
|
|
98
|
+
label="수정"
|
|
99
|
+
@click="emit('edit', postId)"
|
|
100
|
+
/>
|
|
101
|
+
<UButton
|
|
102
|
+
v-if="canDelete(post)"
|
|
103
|
+
icon="i-lucide-trash-2"
|
|
104
|
+
size="sm"
|
|
105
|
+
variant="outline"
|
|
106
|
+
color="error"
|
|
107
|
+
label="삭제"
|
|
108
|
+
:loading="deleting"
|
|
109
|
+
@click="handleDelete"
|
|
110
|
+
/>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
<div class="flex items-center gap-3 text-sm text-muted">
|
|
114
|
+
<UAvatar
|
|
115
|
+
:src="post.authorPhoto ?? undefined"
|
|
116
|
+
:alt="post.authorName ?? '익명'"
|
|
117
|
+
size="xs"
|
|
118
|
+
/>
|
|
119
|
+
<span>{{ post.authorName ?? '익명' }}</span>
|
|
120
|
+
<span>{{ formatDate(post.createdAt) }}</span>
|
|
121
|
+
</div>
|
|
122
|
+
<div
|
|
123
|
+
v-if="post.tags?.length"
|
|
124
|
+
class="flex gap-1"
|
|
125
|
+
>
|
|
126
|
+
<UBadge
|
|
127
|
+
v-for="tag in post.tags"
|
|
128
|
+
:key="tag"
|
|
129
|
+
variant="subtle"
|
|
130
|
+
color="neutral"
|
|
131
|
+
size="sm"
|
|
132
|
+
>
|
|
133
|
+
{{ tag }}
|
|
134
|
+
</UBadge>
|
|
135
|
+
</div>
|
|
136
|
+
</header>
|
|
137
|
+
|
|
138
|
+
<p class="whitespace-pre-wrap break-words leading-relaxed">
|
|
139
|
+
{{ post.content }}
|
|
140
|
+
</p>
|
|
141
|
+
|
|
142
|
+
<MemiBoardAttachments
|
|
143
|
+
v-if="post.attachments?.length"
|
|
144
|
+
:model-value="post.attachments"
|
|
145
|
+
:post-id="postId"
|
|
146
|
+
/>
|
|
147
|
+
|
|
148
|
+
<section class="flex flex-col gap-4 border-t border-default pt-4">
|
|
149
|
+
<h2 class="text-sm font-medium text-muted">
|
|
150
|
+
댓글 {{ comments.length }}
|
|
151
|
+
</h2>
|
|
152
|
+
<MemiBoardCommentForm :post-id="postId" />
|
|
153
|
+
<MemiBoardCommentList :post-id="postId" />
|
|
154
|
+
</section>
|
|
155
|
+
</div>
|
|
156
|
+
</template>
|