memi-board 0.4.0 → 0.4.4

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.
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { ref, computed, onMounted } from 'vue'
2
+ import { ref, computed, watch } from 'vue'
3
3
  import type { Attachment } from 'memi-board'
4
4
  import { useMemiBoardAuth } from 'memi-board'
5
5
  import { useMemiBoardPosts } from 'memi-board'
@@ -8,38 +8,87 @@ import { useMemiBoardSettings } from 'memi-board'
8
8
  import MemiBoardAttachments from './Attachments.vue'
9
9
 
10
10
  const props = defineProps<{
11
- /** 지정하면 수정 모드, 지정하지 않으면 새 글 작성 모드. */
11
+ /** 지정하면 수정 모드 */
12
12
  postId?: string
13
+ /**
14
+ * path 등으로 카테고리가 이미 정해진 경우 (예: /board/:category/new).
15
+ * 설정되면 선택 UI 없이 이 값으로 저장한다.
16
+ */
17
+ fixedCategory?: string
13
18
  }>()
14
19
 
15
20
  const emit = defineEmits<{ saved: [id: string], cancel: [] }>()
16
21
 
17
- const { user } = useMemiBoardAuth()
22
+ const { user, isSignedIn } = useMemiBoardAuth()
18
23
  const { getPost, createPost, updatePost } = useMemiBoardPosts()
19
24
  const { checkText } = useMemiBoardModeration()
20
- const { categories } = useMemiBoardSettings()
25
+ const { categories, categoryLabel, ensureSettings, addCategory } = useMemiBoardSettings()
21
26
 
22
27
  const title = ref('')
23
28
  const content = ref('')
24
29
  const tagsInput = ref('')
30
+ /** boardSettings.categories 의 id (fixedCategory 없을 때만 선택) */
25
31
  const category = ref<string | undefined>(undefined)
26
32
  const attachments = ref<Attachment[]>([])
27
33
 
28
- const categoryItems = computed(() => categories.value.map(c => ({ label: c.label, value: c.id })))
34
+ const categoryItems = computed(() =>
35
+ categories.value.map(c => ({ label: c.label, value: c.id })),
36
+ )
29
37
 
30
- const loading = ref(!!props.postId)
38
+ /** 실제 저장에 쓰는 카테고리 id */
39
+ const effectiveCategory = computed(() => props.fixedCategory || category.value)
40
+
41
+ const isEdit = computed(() => Boolean(props.postId))
42
+ const categoryLocked = computed(() => Boolean(props.fixedCategory))
43
+ const loading = ref(false)
31
44
  const saving = ref(false)
45
+ /** 제출 중 단계 표시 (로컬 필터 → AI Logic → 저장) */
46
+ const submitHint = ref('')
47
+ /** 마지막 검열 결과 요약 (화면 + 콘솔) */
48
+ const lastModerationDebug = ref('')
32
49
  const error = ref('')
33
50
 
34
- /** 새 글은 아직 slug가 없으므로 첨부파일 Storage 경로용 임시 네임스페이스를 쓴다. */
51
+ function formatModerationDebug(m: {
52
+ flagged: boolean
53
+ category: string
54
+ reason: string
55
+ error?: boolean
56
+ via?: string
57
+ }): string {
58
+ const viaLabel: Record<string, string> = {
59
+ empty: '빈 텍스트',
60
+ disabled: '검열 끔',
61
+ local: '로컬 금칙어',
62
+ ai: 'Firebase AI Logic',
63
+ 'ai-error-allow': 'AI 실패 → onError:allow (통과)',
64
+ 'ai-error-block': 'AI 실패 → onError:block (차단)',
65
+ }
66
+ const via = m.via ? (viaLabel[m.via] || m.via) : '?'
67
+ return [
68
+ `via: ${via}`,
69
+ `flagged: ${m.flagged}`,
70
+ `category: ${m.category}`,
71
+ m.reason ? `reason: ${m.reason}` : '',
72
+ m.error ? 'apiError: true' : '',
73
+ ].filter(Boolean).join(' · ')
74
+ }
75
+
76
+ const showAddCategory = ref(false)
77
+ const newCategoryLabel = ref('')
78
+ const addingCategory = ref(false)
79
+
35
80
  const attachmentNamespace = ref(props.postId ?? `new-${Date.now()}`)
36
81
 
37
- onMounted(async () => {
38
- if (!props.postId) return
82
+ async function loadPost(id: string) {
83
+ loading.value = true
84
+ error.value = ''
39
85
  try {
40
- const post = await getPost(props.postId)
86
+ await ensureSettings().catch(() => {})
87
+ const post = await getPost(id)
41
88
  if (!post) {
42
89
  error.value = '게시글을 찾을 수 없습니다.'
90
+ title.value = ''
91
+ content.value = ''
43
92
  return
44
93
  }
45
94
  title.value = post.title
@@ -47,11 +96,75 @@ onMounted(async () => {
47
96
  tagsInput.value = (post.tags ?? []).join(', ')
48
97
  category.value = post.category
49
98
  attachments.value = post.attachments ?? []
99
+ attachmentNamespace.value = id
100
+ }
101
+ catch (e) {
102
+ const msg = (e as Error).message || String(e)
103
+ error.value = msg.includes('permission')
104
+ ? '글을 불러올 권한이 없습니다. 로그인 상태를 확인해 주세요.'
105
+ : `글을 불러오지 못했습니다: ${msg}`
50
106
  }
51
107
  finally {
52
108
  loading.value = false
53
109
  }
54
- })
110
+ }
111
+
112
+ watch(
113
+ () => props.postId,
114
+ (id) => {
115
+ if (id) void loadPost(id)
116
+ else {
117
+ loading.value = false
118
+ attachmentNamespace.value = `new-${Date.now()}`
119
+ if (props.fixedCategory) category.value = props.fixedCategory
120
+ void ensureSettings().catch(() => {})
121
+ }
122
+ },
123
+ { immediate: true },
124
+ )
125
+
126
+ watch(
127
+ () => props.fixedCategory,
128
+ (c) => {
129
+ if (c && !props.postId) category.value = c
130
+ },
131
+ { immediate: true },
132
+ )
133
+
134
+ async function handleAddCategory() {
135
+ error.value = ''
136
+ if (!isSignedIn.value) {
137
+ error.value = '카테고리 추가에는 로그인이 필요합니다.'
138
+ return
139
+ }
140
+ addingCategory.value = true
141
+ try {
142
+ const label = newCategoryLabel.value.trim()
143
+ const moderation = await checkText(label)
144
+ if (moderation.flagged) {
145
+ error.value = moderation.reason || '사용할 수 없는 카테고리 이름입니다.'
146
+ return
147
+ }
148
+ const id = await addCategory(label)
149
+ category.value = id
150
+ newCategoryLabel.value = ''
151
+ showAddCategory.value = false
152
+ }
153
+ catch (e) {
154
+ error.value = (e as Error).message || String(e)
155
+ }
156
+ finally {
157
+ addingCategory.value = false
158
+ }
159
+ }
160
+
161
+ function friendlyWriteError(e: unknown): string {
162
+ const msg = e instanceof Error ? e.message : String(e)
163
+ if (msg.includes('permission-denied') || msg.includes('Permission denied')) {
164
+ return '수정 권한이 없습니다. 본인이 작성한 글인지, 로그인이 유지되는지 확인해 주세요.'
165
+ }
166
+ return msg
167
+ }
55
168
 
56
169
  async function handleSubmit() {
57
170
  error.value = ''
@@ -63,28 +176,53 @@ async function handleSubmit() {
63
176
  error.value = '로그인이 필요합니다.'
64
177
  return
65
178
  }
179
+ const cat = effectiveCategory.value
180
+ if (!cat) {
181
+ error.value = '카테고리를 선택해 주세요.'
182
+ return
183
+ }
184
+ if (!categories.value.some(c => c.id === cat) && !props.fixedCategory) {
185
+ error.value = '목록에 있는 카테고리를 선택해 주세요.'
186
+ return
187
+ }
66
188
 
67
189
  saving.value = true
190
+ submitHint.value = '내용을 검토하는 중… (로컬 → AI Logic)'
191
+ lastModerationDebug.value = ''
68
192
  try {
69
- const moderation = await checkText(`${title.value}\n${content.value}`)
193
+ const text = `${title.value}\n${content.value}`
194
+ console.info('[memi-board] post checkText 시작', { title: title.value, contentLen: content.value.length })
195
+ const moderation = await checkText(text)
196
+ lastModerationDebug.value = formatModerationDebug(moderation)
197
+ console.info('[memi-board] post checkText 끝', lastModerationDebug.value, moderation)
198
+
70
199
  if (moderation.flagged) {
71
200
  error.value = moderation.reason || '게시할 수 없는 내용이 포함되어 있습니다.'
72
201
  return
73
202
  }
74
203
 
204
+ if (moderation.via === 'ai-error-allow') {
205
+ submitHint.value = 'AI 검열 실패 — 설정상 통과 후 저장…'
206
+ }
207
+ else {
208
+ submitHint.value = '저장하는 중…'
209
+ }
75
210
  const tags = tagsInput.value.split(',').map(t => t.trim()).filter(Boolean)
211
+ const payload = {
212
+ title: title.value.trim(),
213
+ content: content.value,
214
+ tags,
215
+ category: cat,
216
+ attachments: attachments.value,
217
+ }
76
218
 
77
219
  if (props.postId) {
78
- await updatePost(props.postId, { title: title.value, content: content.value, tags, category: category.value, attachments: attachments.value })
220
+ await updatePost(props.postId, payload)
79
221
  emit('saved', props.postId)
80
222
  }
81
223
  else {
82
224
  const id = await createPost({
83
- title: title.value,
84
- content: content.value,
85
- tags,
86
- category: category.value,
87
- attachments: attachments.value,
225
+ ...payload,
88
226
  authorUid: user.value.uid,
89
227
  authorName: user.value.displayName,
90
228
  authorPhoto: user.value.photoURL,
@@ -93,10 +231,11 @@ async function handleSubmit() {
93
231
  }
94
232
  }
95
233
  catch (e) {
96
- error.value = (e as Error).message
234
+ error.value = friendlyWriteError(e)
97
235
  }
98
236
  finally {
99
237
  saving.value = false
238
+ submitHint.value = ''
100
239
  }
101
240
  }
102
241
  </script>
@@ -115,12 +254,68 @@ async function handleSubmit() {
115
254
  class="flex flex-col gap-4"
116
255
  @submit.prevent="handleSubmit"
117
256
  >
118
- <USelect
119
- v-model="category"
120
- :items="categoryItems"
121
- placeholder="카테고리 선택"
122
- class="w-40"
123
- />
257
+ <!-- path 로 카테고리 고정 시: 표시만 / 아니면 선택+추가 -->
258
+ <div
259
+ v-if="categoryLocked"
260
+ class="flex items-center gap-2 text-sm"
261
+ >
262
+ <span class="text-muted">카테고리</span>
263
+ <UBadge
264
+ :label="categoryLabel(fixedCategory) || fixedCategory"
265
+ variant="subtle"
266
+ />
267
+ </div>
268
+
269
+ <div
270
+ v-else
271
+ class="flex flex-col gap-2"
272
+ >
273
+ <div class="flex items-center gap-2 flex-wrap">
274
+ <USelect
275
+ v-model="category"
276
+ :items="categoryItems"
277
+ placeholder="카테고리 선택"
278
+ class="w-48"
279
+ />
280
+ <UButton
281
+ v-if="isSignedIn"
282
+ type="button"
283
+ variant="outline"
284
+ color="neutral"
285
+ size="sm"
286
+ icon="i-lucide-plus"
287
+ label="카테고리 추가"
288
+ @click="showAddCategory = !showAddCategory"
289
+ />
290
+ </div>
291
+ <div
292
+ v-if="showAddCategory"
293
+ class="flex items-center gap-2"
294
+ >
295
+ <UInput
296
+ v-model="newCategoryLabel"
297
+ placeholder="새 카테고리 이름"
298
+ class="w-48"
299
+ @keydown.enter.prevent="handleAddCategory"
300
+ />
301
+ <UButton
302
+ type="button"
303
+ size="sm"
304
+ label="추가"
305
+ :loading="addingCategory"
306
+ @click="handleAddCategory"
307
+ />
308
+ <UButton
309
+ type="button"
310
+ size="sm"
311
+ variant="ghost"
312
+ color="neutral"
313
+ label="취소"
314
+ @click="showAddCategory = false; newCategoryLabel = ''"
315
+ />
316
+ </div>
317
+ </div>
318
+
124
319
  <UInput
125
320
  v-model="title"
126
321
  placeholder="제목"
@@ -150,12 +345,24 @@ async function handleSubmit() {
150
345
  >
151
346
  {{ error }}
152
347
  </p>
348
+ <p
349
+ v-else-if="submitHint"
350
+ class="text-sm text-muted"
351
+ >
352
+ {{ submitHint }}
353
+ </p>
354
+ <p
355
+ v-if="lastModerationDebug"
356
+ class="text-xs font-mono text-muted break-all rounded-md bg-muted/40 px-2 py-1.5"
357
+ >
358
+ 검열 디버그: {{ lastModerationDebug }}
359
+ </p>
153
360
 
154
361
  <div class="flex gap-2">
155
362
  <UButton
156
363
  type="submit"
157
364
  :loading="saving"
158
- :label="postId ? '수정 완료' : '게시하기'"
365
+ :label="isEdit ? '수정 완료' : '게시하기'"
159
366
  />
160
367
  <UButton
161
368
  variant="ghost"
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { ref, onMounted } from 'vue'
2
+ import { ref, watch } from 'vue'
3
3
  import { RouterLink } from 'vue-router'
4
4
  import type { DocumentData, QueryDocumentSnapshot } from 'firebase/firestore'
5
5
  import { useMemiBoardPosts } from 'memi-board'
@@ -9,7 +9,20 @@ import { formatDate } from 'memi-board'
9
9
 
10
10
  const props = withDefaults(defineProps<{
11
11
  pageSize?: number
12
- /** 지정하면 각 글이 `${linkBase}/${id}`로 이동하는 링크가 된다. 지정하지 않으면 select 이벤트만 emit. */
12
+ /**
13
+ * 글 상세 링크 생성.
14
+ * 예: (p) => `/board/${p.category}/${p.id}`
15
+ * 없으면 postLinkBase / linkBase / select 순.
16
+ */
17
+ getPostLink?: (post: PostModel) => string | undefined
18
+ /**
19
+ * 글 상세 링크 prefix.
20
+ * 예: `/board/post` → `/board/post/{id}`
21
+ */
22
+ postLinkBase?: string
23
+ /** 지정 시 해당 카테고리 글만 조회 */
24
+ category?: string
25
+ /** @deprecated postLinkBase / getPostLink 사용 */
13
26
  linkBase?: string
14
27
  }>(), {
15
28
  pageSize: 20,
@@ -25,25 +38,52 @@ const cursor = ref<QueryDocumentSnapshot<DocumentData> | undefined>(undefined)
25
38
  const hasMore = ref(false)
26
39
  const loading = ref(false)
27
40
  const initialLoading = ref(true)
41
+ const loadError = ref('')
28
42
 
29
- async function loadMore() {
43
+ function postTo(post: PostModel): string | undefined {
44
+ if (props.getPostLink) return props.getPostLink(post)
45
+ const base = props.postLinkBase || props.linkBase
46
+ if (!base || !post.id) return undefined
47
+ return `${base.replace(/\/$/, '')}/${post.id}`
48
+ }
49
+
50
+ async function loadMore(reset = false) {
30
51
  loading.value = true
52
+ loadError.value = ''
53
+ if (reset) {
54
+ posts.value = []
55
+ cursor.value = undefined
56
+ hasMore.value = false
57
+ initialLoading.value = true
58
+ }
31
59
  try {
32
- const result = await getPosts({ pageSize: props.pageSize, cursor: cursor.value })
60
+ const result = await getPosts({
61
+ pageSize: props.pageSize,
62
+ cursor: reset ? undefined : cursor.value,
63
+ category: props.category || undefined,
64
+ })
33
65
  posts.value.push(...result.posts)
34
66
  cursor.value = result.cursor
35
67
  hasMore.value = result.hasMore
36
68
  }
69
+ catch (e) {
70
+ loadError.value = (e as Error).message || String(e)
71
+ console.error('[memi-board] getPosts failed', e)
72
+ }
37
73
  finally {
38
74
  loading.value = false
39
75
  initialLoading.value = false
40
76
  }
41
77
  }
42
78
 
43
- onMounted(loadMore)
79
+ watch(
80
+ () => props.category,
81
+ () => { void loadMore(true) },
82
+ { immediate: true },
83
+ )
44
84
 
45
85
  function handleClick(post: PostModel) {
46
- if (!props.linkBase) emit('select', post)
86
+ if (!postTo(post)) emit('select', post)
47
87
  }
48
88
  </script>
49
89
 
@@ -57,6 +97,13 @@ function handleClick(post: PostModel) {
57
97
  />
58
98
  </template>
59
99
 
100
+ <p
101
+ v-else-if="loadError"
102
+ class="text-sm text-error text-center py-8"
103
+ >
104
+ 목록을 불러오지 못했습니다: {{ loadError }}
105
+ </p>
106
+
60
107
  <p
61
108
  v-else-if="!posts.length"
62
109
  class="text-sm text-muted text-center py-8"
@@ -65,10 +112,10 @@ function handleClick(post: PostModel) {
65
112
  </p>
66
113
 
67
114
  <component
68
- :is="linkBase ? RouterLink : 'button'"
115
+ :is="postTo(post) ? RouterLink : 'button'"
69
116
  v-for="post in posts"
70
117
  :key="post.id"
71
- :to="linkBase ? `${linkBase}/${post.id}` : undefined"
118
+ :to="postTo(post)"
72
119
  class="text-left"
73
120
  @click="handleClick(post)"
74
121
  >
@@ -115,7 +162,7 @@ function handleClick(post: PostModel) {
115
162
  label="더 보기"
116
163
  block
117
164
  :loading="loading"
118
- @click="loadMore"
165
+ @click="loadMore(false)"
119
166
  />
120
167
  </div>
121
168
  </template>
package/dist/types.d.ts CHANGED
@@ -53,10 +53,14 @@ export interface BoardSettingsModel {
53
53
  categories: BoardCategory[];
54
54
  updatedAt?: Timestamp;
55
55
  }
56
+ /** 검열이 어느 단계에서 끝났는지 (디버그·UI 표시용) */
57
+ export type ModerationVia = 'empty' | 'disabled' | 'local' | 'ai' | 'ai-error-allow' | 'ai-error-block';
56
58
  export interface ModerationResult {
57
59
  flagged: boolean;
58
60
  category: 'none' | 'abuse' | 'spam' | 'adult' | 'violence' | 'other';
59
61
  reason: string;
60
62
  /** true면 API/파싱 실패로 판단이 불확실함을 뜻한다 (onError 옵션에 따라 처리). */
61
63
  error?: boolean;
64
+ /** 로컬 리스트 / AI / 실패 후 allow 등 */
65
+ via?: ModerationVia;
62
66
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memi-board",
3
- "version": "0.4.0",
3
+ "version": "0.4.4",
4
4
  "type": "module",
5
5
  "description": "Firebase 게시판 — core composable + thin Nuxt module(UI SFC). Nuxt 4 + Nuxt UI 호스트에 붙임",
6
6
  "license": "MIT",