memi-board 0.3.1 → 0.4.0
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/index.d.ts +2 -8
- package/dist/index.js +162 -814
- package/dist/index.js.map +1 -1
- package/dist/module.d.ts +4 -0
- package/dist/module.mjs +62 -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 +168 -0
- package/dist/runtime/components/List.vue +121 -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,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>
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, computed, onMounted } from 'vue'
|
|
3
|
+
import type { Attachment } from 'memi-board'
|
|
4
|
+
import { useMemiBoardAuth } from 'memi-board'
|
|
5
|
+
import { useMemiBoardPosts } from 'memi-board'
|
|
6
|
+
import { useMemiBoardModeration } from 'memi-board'
|
|
7
|
+
import { useMemiBoardSettings } from 'memi-board'
|
|
8
|
+
import MemiBoardAttachments from './Attachments.vue'
|
|
9
|
+
|
|
10
|
+
const props = defineProps<{
|
|
11
|
+
/** 지정하면 수정 모드, 지정하지 않으면 새 글 작성 모드. */
|
|
12
|
+
postId?: string
|
|
13
|
+
}>()
|
|
14
|
+
|
|
15
|
+
const emit = defineEmits<{ saved: [id: string], cancel: [] }>()
|
|
16
|
+
|
|
17
|
+
const { user } = useMemiBoardAuth()
|
|
18
|
+
const { getPost, createPost, updatePost } = useMemiBoardPosts()
|
|
19
|
+
const { checkText } = useMemiBoardModeration()
|
|
20
|
+
const { categories } = useMemiBoardSettings()
|
|
21
|
+
|
|
22
|
+
const title = ref('')
|
|
23
|
+
const content = ref('')
|
|
24
|
+
const tagsInput = ref('')
|
|
25
|
+
const category = ref<string | undefined>(undefined)
|
|
26
|
+
const attachments = ref<Attachment[]>([])
|
|
27
|
+
|
|
28
|
+
const categoryItems = computed(() => categories.value.map(c => ({ label: c.label, value: c.id })))
|
|
29
|
+
|
|
30
|
+
const loading = ref(!!props.postId)
|
|
31
|
+
const saving = ref(false)
|
|
32
|
+
const error = ref('')
|
|
33
|
+
|
|
34
|
+
/** 새 글은 아직 slug가 없으므로 첨부파일 Storage 경로용 임시 네임스페이스를 쓴다. */
|
|
35
|
+
const attachmentNamespace = ref(props.postId ?? `new-${Date.now()}`)
|
|
36
|
+
|
|
37
|
+
onMounted(async () => {
|
|
38
|
+
if (!props.postId) return
|
|
39
|
+
try {
|
|
40
|
+
const post = await getPost(props.postId)
|
|
41
|
+
if (!post) {
|
|
42
|
+
error.value = '게시글을 찾을 수 없습니다.'
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
title.value = post.title
|
|
46
|
+
content.value = post.content
|
|
47
|
+
tagsInput.value = (post.tags ?? []).join(', ')
|
|
48
|
+
category.value = post.category
|
|
49
|
+
attachments.value = post.attachments ?? []
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
loading.value = false
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
async function handleSubmit() {
|
|
57
|
+
error.value = ''
|
|
58
|
+
if (!title.value.trim() || !content.value.trim()) {
|
|
59
|
+
error.value = '제목과 내용을 입력해 주세요.'
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
if (!user.value) {
|
|
63
|
+
error.value = '로그인이 필요합니다.'
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
saving.value = true
|
|
68
|
+
try {
|
|
69
|
+
const moderation = await checkText(`${title.value}\n${content.value}`)
|
|
70
|
+
if (moderation.flagged) {
|
|
71
|
+
error.value = moderation.reason || '게시할 수 없는 내용이 포함되어 있습니다.'
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const tags = tagsInput.value.split(',').map(t => t.trim()).filter(Boolean)
|
|
76
|
+
|
|
77
|
+
if (props.postId) {
|
|
78
|
+
await updatePost(props.postId, { title: title.value, content: content.value, tags, category: category.value, attachments: attachments.value })
|
|
79
|
+
emit('saved', props.postId)
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
const id = await createPost({
|
|
83
|
+
title: title.value,
|
|
84
|
+
content: content.value,
|
|
85
|
+
tags,
|
|
86
|
+
category: category.value,
|
|
87
|
+
attachments: attachments.value,
|
|
88
|
+
authorUid: user.value.uid,
|
|
89
|
+
authorName: user.value.displayName,
|
|
90
|
+
authorPhoto: user.value.photoURL,
|
|
91
|
+
})
|
|
92
|
+
emit('saved', id)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch (e) {
|
|
96
|
+
error.value = (e as Error).message
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
saving.value = false
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
</script>
|
|
103
|
+
|
|
104
|
+
<template>
|
|
105
|
+
<div
|
|
106
|
+
v-if="loading"
|
|
107
|
+
class="flex flex-col gap-3"
|
|
108
|
+
>
|
|
109
|
+
<USkeleton class="h-10 w-full" />
|
|
110
|
+
<USkeleton class="h-40 w-full" />
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
<form
|
|
114
|
+
v-else
|
|
115
|
+
class="flex flex-col gap-4"
|
|
116
|
+
@submit.prevent="handleSubmit"
|
|
117
|
+
>
|
|
118
|
+
<USelect
|
|
119
|
+
v-model="category"
|
|
120
|
+
:items="categoryItems"
|
|
121
|
+
placeholder="카테고리 선택"
|
|
122
|
+
class="w-40"
|
|
123
|
+
/>
|
|
124
|
+
<UInput
|
|
125
|
+
v-model="title"
|
|
126
|
+
placeholder="제목"
|
|
127
|
+
size="lg"
|
|
128
|
+
required
|
|
129
|
+
/>
|
|
130
|
+
<UTextarea
|
|
131
|
+
v-model="content"
|
|
132
|
+
placeholder="내용을 입력하세요"
|
|
133
|
+
:rows="12"
|
|
134
|
+
required
|
|
135
|
+
/>
|
|
136
|
+
<UInput
|
|
137
|
+
v-model="tagsInput"
|
|
138
|
+
placeholder="태그 (쉼표로 구분)"
|
|
139
|
+
/>
|
|
140
|
+
|
|
141
|
+
<MemiBoardAttachments
|
|
142
|
+
v-model="attachments"
|
|
143
|
+
:post-id="attachmentNamespace"
|
|
144
|
+
editable
|
|
145
|
+
/>
|
|
146
|
+
|
|
147
|
+
<p
|
|
148
|
+
v-if="error"
|
|
149
|
+
class="text-sm text-error"
|
|
150
|
+
>
|
|
151
|
+
{{ error }}
|
|
152
|
+
</p>
|
|
153
|
+
|
|
154
|
+
<div class="flex gap-2">
|
|
155
|
+
<UButton
|
|
156
|
+
type="submit"
|
|
157
|
+
:loading="saving"
|
|
158
|
+
:label="postId ? '수정 완료' : '게시하기'"
|
|
159
|
+
/>
|
|
160
|
+
<UButton
|
|
161
|
+
variant="ghost"
|
|
162
|
+
color="neutral"
|
|
163
|
+
label="취소"
|
|
164
|
+
@click="emit('cancel')"
|
|
165
|
+
/>
|
|
166
|
+
</div>
|
|
167
|
+
</form>
|
|
168
|
+
</template>
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, onMounted } from 'vue'
|
|
3
|
+
import { RouterLink } from 'vue-router'
|
|
4
|
+
import type { DocumentData, QueryDocumentSnapshot } from 'firebase/firestore'
|
|
5
|
+
import { useMemiBoardPosts } from 'memi-board'
|
|
6
|
+
import { useMemiBoardSettings } from 'memi-board'
|
|
7
|
+
import type { PostModel } from 'memi-board'
|
|
8
|
+
import { formatDate } from 'memi-board'
|
|
9
|
+
|
|
10
|
+
const props = withDefaults(defineProps<{
|
|
11
|
+
pageSize?: number
|
|
12
|
+
/** 지정하면 각 글이 `${linkBase}/${id}`로 이동하는 링크가 된다. 지정하지 않으면 select 이벤트만 emit. */
|
|
13
|
+
linkBase?: string
|
|
14
|
+
}>(), {
|
|
15
|
+
pageSize: 20,
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const emit = defineEmits<{ select: [post: PostModel] }>()
|
|
19
|
+
|
|
20
|
+
const { getPosts } = useMemiBoardPosts()
|
|
21
|
+
const { categoryLabel } = useMemiBoardSettings()
|
|
22
|
+
|
|
23
|
+
const posts = ref<PostModel[]>([])
|
|
24
|
+
const cursor = ref<QueryDocumentSnapshot<DocumentData> | undefined>(undefined)
|
|
25
|
+
const hasMore = ref(false)
|
|
26
|
+
const loading = ref(false)
|
|
27
|
+
const initialLoading = ref(true)
|
|
28
|
+
|
|
29
|
+
async function loadMore() {
|
|
30
|
+
loading.value = true
|
|
31
|
+
try {
|
|
32
|
+
const result = await getPosts({ pageSize: props.pageSize, cursor: cursor.value })
|
|
33
|
+
posts.value.push(...result.posts)
|
|
34
|
+
cursor.value = result.cursor
|
|
35
|
+
hasMore.value = result.hasMore
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
loading.value = false
|
|
39
|
+
initialLoading.value = false
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
onMounted(loadMore)
|
|
44
|
+
|
|
45
|
+
function handleClick(post: PostModel) {
|
|
46
|
+
if (!props.linkBase) emit('select', post)
|
|
47
|
+
}
|
|
48
|
+
</script>
|
|
49
|
+
|
|
50
|
+
<template>
|
|
51
|
+
<div class="flex flex-col gap-2">
|
|
52
|
+
<template v-if="initialLoading">
|
|
53
|
+
<USkeleton
|
|
54
|
+
v-for="i in 3"
|
|
55
|
+
:key="i"
|
|
56
|
+
class="h-20 w-full"
|
|
57
|
+
/>
|
|
58
|
+
</template>
|
|
59
|
+
|
|
60
|
+
<p
|
|
61
|
+
v-else-if="!posts.length"
|
|
62
|
+
class="text-sm text-muted text-center py-8"
|
|
63
|
+
>
|
|
64
|
+
아직 게시글이 없습니다.
|
|
65
|
+
</p>
|
|
66
|
+
|
|
67
|
+
<component
|
|
68
|
+
:is="linkBase ? RouterLink : 'button'"
|
|
69
|
+
v-for="post in posts"
|
|
70
|
+
:key="post.id"
|
|
71
|
+
:to="linkBase ? `${linkBase}/${post.id}` : undefined"
|
|
72
|
+
class="text-left"
|
|
73
|
+
@click="handleClick(post)"
|
|
74
|
+
>
|
|
75
|
+
<UCard class="hover:bg-elevated/50 transition-colors">
|
|
76
|
+
<div class="flex items-center justify-between gap-4">
|
|
77
|
+
<div class="flex items-center gap-2 min-w-0">
|
|
78
|
+
<UBadge
|
|
79
|
+
v-if="post.category"
|
|
80
|
+
:label="categoryLabel(post.category)"
|
|
81
|
+
variant="subtle"
|
|
82
|
+
size="sm"
|
|
83
|
+
/>
|
|
84
|
+
<h3 class="font-medium truncate">
|
|
85
|
+
{{ post.title }}
|
|
86
|
+
</h3>
|
|
87
|
+
</div>
|
|
88
|
+
<span class="text-xs text-muted shrink-0">{{ formatDate(post.createdAt) }}</span>
|
|
89
|
+
</div>
|
|
90
|
+
<div class="flex items-center gap-3 text-xs text-muted mt-2">
|
|
91
|
+
<span>{{ post.authorName ?? '익명' }}</span>
|
|
92
|
+
<span class="flex items-center gap-1">
|
|
93
|
+
<UIcon
|
|
94
|
+
name="i-lucide-message-circle"
|
|
95
|
+
class="size-3"
|
|
96
|
+
/>{{ post.commentCount }}
|
|
97
|
+
</span>
|
|
98
|
+
<span
|
|
99
|
+
v-if="post.attachments?.length"
|
|
100
|
+
class="flex items-center gap-1"
|
|
101
|
+
>
|
|
102
|
+
<UIcon
|
|
103
|
+
name="i-lucide-paperclip"
|
|
104
|
+
class="size-3"
|
|
105
|
+
/>{{ post.attachments.length }}
|
|
106
|
+
</span>
|
|
107
|
+
</div>
|
|
108
|
+
</UCard>
|
|
109
|
+
</component>
|
|
110
|
+
|
|
111
|
+
<UButton
|
|
112
|
+
v-if="hasMore"
|
|
113
|
+
variant="outline"
|
|
114
|
+
color="neutral"
|
|
115
|
+
label="더 보기"
|
|
116
|
+
block
|
|
117
|
+
:loading="loading"
|
|
118
|
+
@click="loadMore"
|
|
119
|
+
/>
|
|
120
|
+
</div>
|
|
121
|
+
</template>
|