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.
@@ -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>