memi-board 0.4.5 → 0.4.12

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,83 +1,191 @@
1
1
  <script setup lang="ts">
2
+ /**
3
+ * 글쓰기 폼 — Nuxt UI UEditor + shineb PostEditor 패턴.
4
+ * @see https://ui.nuxt.com/docs/components/editor
5
+ * @see shineb app/components/PostEditor.vue
6
+ *
7
+ * 커스텀은 handlers.image(파일 업로드) + DOM paste/drop 만.
8
+ * TipTap Extension / PluginKey 는 쓰지 않음 (유지보수·충돌 회피).
9
+ */
2
10
  import { ref, computed, watch } 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'
11
+ import type { Attachment, EditorImageEntry } from 'memi-board'
12
+ import {
13
+ useMemiBoardAuth,
14
+ useMemiBoardPosts,
15
+ useMemiBoardModeration,
16
+ useMemiBoardSettings,
17
+ useMemiBoardStorage,
18
+ EDITOR_IMAGE_MAX_BYTES,
19
+ } from 'memi-board'
8
20
  import MemiBoardAttachments from './Attachments.vue'
9
21
 
10
22
  const props = defineProps<{
11
- /** 지정하면 수정 모드 */
12
23
  postId?: string
13
- /**
14
- * path 등으로 카테고리가 이미 정해진 경우 (예: /board/:category/new).
15
- * 설정되면 선택 UI 없이 이 값으로 저장한다.
16
- */
17
24
  fixedCategory?: string
18
25
  }>()
19
26
 
20
27
  const emit = defineEmits<{ saved: [id: string], cancel: [] }>()
21
28
 
22
- const { user, isSignedIn } = useMemiBoardAuth()
29
+ const { user, isSignedIn, isWriteRestricted, restrictedMessage } = useMemiBoardAuth()
23
30
  const { getPost, createPost, updatePost } = useMemiBoardPosts()
24
31
  const { checkText } = useMemiBoardModeration()
25
32
  const { categories, categoryLabel, ensureSettings, addCategory } = useMemiBoardSettings()
33
+ const { uploadEditorImage } = useMemiBoardStorage()
26
34
 
27
35
  const title = ref('')
36
+ /** UEditor content-type=html (shineb 와 동일) */
28
37
  const content = ref('')
29
38
  const tagsInput = ref('')
30
- /** boardSettings.categories 의 id (fixedCategory 없을 때만 선택) */
31
39
  const category = ref<string | undefined>(undefined)
32
40
  const attachments = ref<Attachment[]>([])
33
41
 
34
42
  const categoryItems = computed(() =>
35
43
  categories.value.map(c => ({ label: c.label, value: c.id })),
36
44
  )
37
-
38
- /** 실제 저장에 쓰는 카테고리 id */
39
45
  const effectiveCategory = computed(() => props.fixedCategory || category.value)
40
-
41
46
  const isEdit = computed(() => Boolean(props.postId))
42
47
  const categoryLocked = computed(() => Boolean(props.fixedCategory))
48
+
43
49
  const loading = ref(false)
44
50
  const saving = ref(false)
45
- /** 제출 중 단계 표시 (로컬 필터 → AI Logic → 저장) */
46
51
  const submitHint = ref('')
47
- /** 마지막 검열 결과 요약 (화면 + 콘솔) */
48
- const lastModerationDebug = ref('')
49
52
  const error = ref('')
50
-
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
- }
53
+ const imageUploading = ref(false)
54
+ const imageUploadError = ref('')
75
55
 
76
56
  const showAddCategory = ref(false)
77
57
  const newCategoryLabel = ref('')
78
58
  const addingCategory = ref(false)
79
59
 
80
60
  const attachmentNamespace = ref(props.postId ?? `new-${Date.now()}`)
61
+ const uploadedEditorImages = ref<EditorImageEntry[]>([])
62
+ const editorRef = ref<{ editor?: unknown } | null>(null)
63
+
64
+ function imageNamespace(): string {
65
+ return props.postId || attachmentNamespace.value
66
+ }
67
+
68
+ async function doUploadImage(file: File): Promise<EditorImageEntry> {
69
+ if (!file.type.startsWith('image/')) {
70
+ throw new Error('이미지 파일만 업로드할 수 있습니다.')
71
+ }
72
+ if (file.size > EDITOR_IMAGE_MAX_BYTES) {
73
+ throw new Error(`이미지는 ${EDITOR_IMAGE_MAX_BYTES / 1024 / 1024}MB 이하여야 합니다.`)
74
+ }
75
+ const entry = await uploadEditorImage(file, imageNamespace())
76
+ uploadedEditorImages.value.push(entry)
77
+ return entry
78
+ }
79
+
80
+ async function uploadAndSetImage(editor: any, file: File) {
81
+ imageUploading.value = true
82
+ imageUploadError.value = ''
83
+ try {
84
+ const entry = await doUploadImage(file)
85
+ editor.chain().focus().setImage({ src: entry.originalUrl }).run()
86
+ }
87
+ catch (e) {
88
+ imageUploadError.value = (e as Error).message || '이미지 업로드에 실패했습니다.'
89
+ }
90
+ finally {
91
+ imageUploading.value = false
92
+ }
93
+ }
94
+
95
+ function pickAndUploadImage(editor: any) {
96
+ const input = document.createElement('input')
97
+ input.type = 'file'
98
+ input.accept = 'image/*'
99
+ input.onchange = () => {
100
+ const file = input.files?.[0]
101
+ if (file) void uploadAndSetImage(editor, file)
102
+ }
103
+ input.click()
104
+ }
105
+
106
+ /** 공식 handlers API — 기본 image(URL prompt) 를 파일 업로드로 교체 (shineb) */
107
+ const handlers = {
108
+ image: {
109
+ canExecute: (editor: any) => editor.can().setImage({ src: '' }),
110
+ isActive: (editor: any) => editor.isActive('image'),
111
+ isDisabled: (editor: any) =>
112
+ !editor.extensionManager?.extensions?.some((ext: any) => ext.name === 'image'),
113
+ execute: (editor: any) => {
114
+ pickAndUploadImage(editor)
115
+ return editor.chain()
116
+ },
117
+ },
118
+ }
119
+
120
+ const toolbarItems = [
121
+ [
122
+ { kind: 'heading', level: 1, icon: 'i-lucide-heading-1' },
123
+ { kind: 'heading', level: 2, icon: 'i-lucide-heading-2' },
124
+ { kind: 'heading', level: 3, icon: 'i-lucide-heading-3' },
125
+ ],
126
+ [
127
+ { kind: 'mark', mark: 'bold', icon: 'i-lucide-bold' },
128
+ { kind: 'mark', mark: 'italic', icon: 'i-lucide-italic' },
129
+ { kind: 'mark', mark: 'underline', icon: 'i-lucide-underline' },
130
+ { kind: 'mark', mark: 'strike', icon: 'i-lucide-strikethrough' },
131
+ { kind: 'mark', mark: 'code', icon: 'i-lucide-code' },
132
+ ],
133
+ [
134
+ { kind: 'bulletList', icon: 'i-lucide-list' },
135
+ { kind: 'orderedList', icon: 'i-lucide-list-ordered' },
136
+ { kind: 'blockquote', icon: 'i-lucide-quote' },
137
+ { kind: 'codeBlock', icon: 'i-lucide-square-code' },
138
+ ],
139
+ [
140
+ { kind: 'link', icon: 'i-lucide-link' },
141
+ { kind: 'image', icon: 'i-lucide-image' },
142
+ ],
143
+ [
144
+ { kind: 'horizontalRule', icon: 'i-lucide-minus' },
145
+ { kind: 'undo', icon: 'i-lucide-undo-2' },
146
+ { kind: 'redo', icon: 'i-lucide-redo-2' },
147
+ { kind: 'clearFormatting', icon: 'i-lucide-remove-formatting' },
148
+ ],
149
+ ] as const
150
+
151
+ function isContentEmpty(html: string): boolean {
152
+ const plain = html
153
+ .replace(/<[^>]+>/g, ' ')
154
+ .replace(/&nbsp;/g, ' ')
155
+ .replace(/\s+/g, ' ')
156
+ .trim()
157
+ return !plain
158
+ }
159
+
160
+ function resolveEditor(): any | null {
161
+ const ed = (editorRef.value as any)?.editor
162
+ return ed?.value ?? ed ?? null
163
+ }
164
+
165
+ /** 앱 레벨 paste/drop (TipTap Extension 아님 — shineb 툴바 업로드와 동일 업로드 경로) */
166
+ function onEditorRootPaste(e: ClipboardEvent) {
167
+ const items = Array.from(e.clipboardData?.items ?? [])
168
+ const imageItem = items.find(i => i.type.startsWith('image/'))
169
+ if (!imageItem) return
170
+ const file = imageItem.getAsFile()
171
+ const editor = resolveEditor()
172
+ if (!file || !editor) return
173
+ e.preventDefault()
174
+ void uploadAndSetImage(editor, file)
175
+ }
176
+
177
+ function onEditorRootDrop(e: DragEvent) {
178
+ const files = Array.from(e.dataTransfer?.files ?? [])
179
+ const image = files.find(f => f.type.startsWith('image/'))
180
+ const editor = resolveEditor()
181
+ if (!image || !editor) return
182
+ e.preventDefault()
183
+ void uploadAndSetImage(editor, image)
184
+ }
185
+
186
+ function onEditorRootDragOver(e: DragEvent) {
187
+ if (Array.from(e.dataTransfer?.types ?? []).includes('Files')) e.preventDefault()
188
+ }
81
189
 
82
190
  async function loadPost(id: string) {
83
191
  loading.value = true
@@ -168,7 +276,7 @@ function friendlyWriteError(e: unknown): string {
168
276
 
169
277
  async function handleSubmit() {
170
278
  error.value = ''
171
- if (!title.value.trim() || !content.value.trim()) {
279
+ if (!title.value.trim() || isContentEmpty(content.value)) {
172
280
  error.value = '제목과 내용을 입력해 주세요.'
173
281
  return
174
282
  }
@@ -185,28 +293,23 @@ async function handleSubmit() {
185
293
  error.value = '목록에 있는 카테고리를 선택해 주세요.'
186
294
  return
187
295
  }
296
+ if (isWriteRestricted.value) {
297
+ error.value = restrictedMessage.value
298
+ || '콘텐츠 경고가 누적되어 글·댓글 작성이 잠시 제한됐어요.'
299
+ return
300
+ }
188
301
 
189
302
  saving.value = true
190
- submitHint.value = '내용을 검토하는 중… (로컬 → AI Logic)'
191
- lastModerationDebug.value = ''
303
+ submitHint.value = '내용을 검토하는 중…'
192
304
  try {
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
-
305
+ const plain = content.value.replace(/<[^>]+>/g, ' ')
306
+ const moderation = await checkText(`${title.value}\n${plain}`)
199
307
  if (moderation.flagged) {
200
308
  error.value = moderation.reason || '게시할 수 없는 내용이 포함되어 있습니다.'
201
309
  return
202
310
  }
203
311
 
204
- if (moderation.via === 'ai-error-allow') {
205
- submitHint.value = 'AI 검열 실패 — 설정상 통과 후 저장…'
206
- }
207
- else {
208
- submitHint.value = '저장하는 중…'
209
- }
312
+ submitHint.value = '저장하는 중…'
210
313
  const tags = tagsInput.value.split(',').map(t => t.trim()).filter(Boolean)
211
314
  const payload = {
212
315
  title: title.value.trim(),
@@ -254,7 +357,6 @@ async function handleSubmit() {
254
357
  class="flex flex-col gap-4"
255
358
  @submit.prevent="handleSubmit"
256
359
  >
257
- <!-- path 로 카테고리 고정 시: 표시만 / 아니면 선택+추가 -->
258
360
  <div
259
361
  v-if="categoryLocked"
260
362
  class="flex items-center gap-2 text-sm"
@@ -322,12 +424,54 @@ async function handleSubmit() {
322
424
  size="lg"
323
425
  required
324
426
  />
325
- <UTextarea
326
- v-model="content"
327
- placeholder="내용을 입력하세요"
328
- :rows="12"
329
- required
330
- />
427
+
428
+ <div
429
+ class="rounded-xl border border-default overflow-hidden"
430
+ @paste="onEditorRootPaste"
431
+ @drop="onEditorRootDrop"
432
+ @dragover="onEditorRootDragOver"
433
+ >
434
+ <UAlert
435
+ v-if="imageUploadError"
436
+ color="error"
437
+ variant="subtle"
438
+ icon="i-lucide-circle-alert"
439
+ title="업로드 실패"
440
+ :description="imageUploadError"
441
+ class="rounded-none rounded-t-xl"
442
+ />
443
+
444
+ <UEditor
445
+ ref="editorRef"
446
+ v-model="content"
447
+ content-type="html"
448
+ :handlers="handlers"
449
+ placeholder="본문을 입력하세요…"
450
+ :ui="{ content: 'min-h-64 p-4' }"
451
+ class="w-full"
452
+ >
453
+ <template #default="{ editor }">
454
+ <UEditorToolbar
455
+ :editor="editor"
456
+ :items="toolbarItems"
457
+ class="border-b border-default p-2"
458
+ />
459
+ <UEditorDragHandle :editor="editor" />
460
+ </template>
461
+ </UEditor>
462
+
463
+ <p
464
+ v-if="imageUploading"
465
+ class="flex items-center gap-2 border-t border-default px-4 py-2 text-xs text-muted"
466
+ >
467
+ <UIcon
468
+ name="i-lucide-loader-circle"
469
+ class="size-3.5 animate-spin"
470
+ />
471
+ 이미지 업로드 중…
472
+ </p>
473
+ </div>
474
+
331
475
  <UInput
332
476
  v-model="tagsInput"
333
477
  placeholder="태그 (쉼표로 구분)"
@@ -339,6 +483,12 @@ async function handleSubmit() {
339
483
  editable
340
484
  />
341
485
 
486
+ <p
487
+ v-if="isWriteRestricted && restrictedMessage"
488
+ class="text-sm text-warning"
489
+ >
490
+ {{ restrictedMessage }}
491
+ </p>
342
492
  <p
343
493
  v-if="error"
344
494
  class="text-sm text-error"
@@ -351,20 +501,16 @@ async function handleSubmit() {
351
501
  >
352
502
  {{ submitHint }}
353
503
  </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>
360
504
 
361
505
  <div class="flex gap-2">
362
506
  <UButton
363
507
  type="submit"
364
508
  :loading="saving"
509
+ :disabled="isWriteRestricted || imageUploading"
365
510
  :label="isEdit ? '수정 완료' : '게시하기'"
366
511
  />
367
512
  <UButton
513
+ type="button"
368
514
  variant="ghost"
369
515
  color="neutral"
370
516
  label="취소"
package/dist/types.d.ts CHANGED
@@ -6,6 +6,18 @@ export interface Attachment {
6
6
  size: number;
7
7
  type: string;
8
8
  }
9
+ /**
10
+ * 에디터 본문 이미지 업로드 결과 (원본 + 썸네일).
11
+ * Storage: `{prefix}/posts/{postId}/images/...` · `.../images/thumbnails/...`
12
+ * 본문 markdown 에는 originalUrl 만 넣고, 썸네일은 목록 카드 등에 활용 가능.
13
+ * 수정 중 버려진 파일은 호스트 스케줄러로 정리 (본문에 없는 path).
14
+ */
15
+ export interface EditorImageEntry {
16
+ originalUrl: string;
17
+ originalPath: string;
18
+ thumbnailUrl: string;
19
+ thumbnailPath: string;
20
+ }
9
21
  /** MVP: 차단된 글은 쓰기 자체가 일어나지 않으므로 항상 'approved'만 저장된다. */
10
22
  export type ModerationStatus = 'approved';
11
23
  export interface PostModel {
@@ -53,14 +65,14 @@ export interface BoardSettingsModel {
53
65
  categories: BoardCategory[];
54
66
  updatedAt?: Timestamp;
55
67
  }
56
- /** 검열이 어느 단계에서 끝났는지 (디버그·UI 표시용) */
57
- export type ModerationVia = 'empty' | 'disabled' | 'local' | 'ai' | 'ai-error-allow' | 'ai-error-block';
68
+ /** 검열이 어느 단계에서 끝났는지 (호스트/로깅용, UI 노출 없음) */
69
+ export type ModerationVia = 'empty' | 'disabled' | 'local' | 'ai' | 'restricted' | 'ai-error-allow' | 'ai-error-block';
58
70
  export interface ModerationResult {
59
71
  flagged: boolean;
60
72
  category: 'none' | 'abuse' | 'spam' | 'adult' | 'violence' | 'other';
61
73
  reason: string;
62
- /** true면 API/파싱 실패로 판단이 불확실함을 뜻한다 (onError 옵션에 따라 처리). */
74
+ /** true면 API/파싱 실패로 판단이 불확실함 (onError 옵션에 따라 처리) */
63
75
  error?: boolean;
64
- /** 로컬 리스트 / AI / 실패 후 allow 등 */
76
+ /** 로컬 리스트 / AI / 실패 후 allow|block */
65
77
  via?: ModerationVia;
66
78
  }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Canvas API로 썸네일용 이미지 압축 (shineb 와 동일).
3
+ */
4
+ export declare function compressImage(file: File | Blob, { maxWidth, quality }?: {
5
+ maxWidth?: number;
6
+ quality?: number;
7
+ }): Promise<Blob>;
@@ -0,0 +1,5 @@
1
+ /**
2
+ * 에디터 본문(markdown 또는 html)에서 이미지 URL 추출.
3
+ * 고아 파일 정리 스케줄러·저장 시 참조 비교에 사용.
4
+ */
5
+ export declare function extractEditorImageUrls(content: string): string[];
@@ -1,4 +1,13 @@
1
- export declare const MODERATION_SYSTEM = "\uB2F9\uC2E0\uC740 \uAC8C\uC2DC\uD310 \uCF58\uD150\uCE20\uB97C \uC2EC\uC0AC\uD558\uB294 \uC5C4\uACA9\uD55C \uBAA8\uB354\uB808\uC774\uD130\uC785\uB2C8\uB2E4.\n\uC544\uB798 \uD14D\uC2A4\uD2B8\uC5D0 \uC695\uC124\u00B7\uBE44\uC18D\uC5B4\u00B7\uC131\uD76C\uB871\u00B7\uD610\uC624\u00B7\uC704\uD611\u00B7\uC2A4\uD338\u00B7\uB3C4\uBC30\uC131 \uAD11\uACE0\uAC00 \uC788\uC73C\uBA74 \uBC18\uB4DC\uC2DC \uAC70\uBD80(flagged=true)\uD569\uB2C8\uB2E4.\n\nflagged=true \uC608:\n- \uC695\uC124/\uBE44\uC18D\uC5B4: \uC528\uBC1C, \uC2DC\uBC1C, \uBCD1\uC2E0, \uC9C0\uB784, \uC886, \uAC1C\uC0C8\uB07C, \uC874\uB098, fuck, shit, bitch \uB4F1\n- \uC131\uC801 \uBAA8\uC695, \uD610\uC624 \uBC1C\uC5B8, \uD611\uBC15\n- \uAC8C\uC2DC\uD310 \uC8FC\uC81C\uC640 \uBB34\uAD00\uD55C \uB3C4\uBC30\uC131 \uAD11\uACE0/\uC2A4\uD338\n\nflagged=false:\n- \uAC8C\uC2DC\uD310 \uC8FC\uC81C\uC5D0 \uB9DE\uB294 \uC815\uC0C1\uC801\uC778 \uAE00/\uB313\uAE00 (\uAC15\uD55C \uC758\uACAC\uC774\uB098 \uC815\uB2F9\uD55C \uBE44\uD310\uC740 \uD5C8\uC6A9)\n\nJSON\uB9CC \uCD9C\uB825:\n{\"flagged\":boolean,\"category\":\"none\"|\"abuse\"|\"spam\"|\"adult\"|\"violence\"|\"other\",\"reason\":string}\n- flagged=true\uBA74 category\uB294 \uC0C1\uD669\uC5D0 \uB9DE\uAC8C \uACE0\uB974\uACE0, reason\uC740 \uD55C\uAD6D\uC5B4 1\uBB38\uC7A5 (\uC608: \"\uC695\uC124\uC774 \uD3EC\uD568\uB418\uC5B4 \uC788\uC5B4 \uAC8C\uC2DC\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.\")\n- flagged=false\uBA74 category\uB294 \"none\", reason\uC740 \"\"";
1
+ export declare const MODERATION_SYSTEM = "\uB2F9\uC2E0\uC740 \uAC8C\uC2DC\uD310 \uCF58\uD150\uCE20\uB97C \uC2EC\uC0AC\uD558\uB294 \uC5C4\uACA9\uD55C \uBAA8\uB354\uB808\uC774\uD130\uC785\uB2C8\uB2E4.\n\uC544\uB798 \uD14D\uC2A4\uD2B8\uC5D0 \uC695\uC124\u00B7\uBE44\uC18D\uC5B4\u00B7\uC131\uD76C\uB871\u00B7\uD610\uC624\u00B7\uC704\uD611\u00B7\uC2A4\uD338\u00B7\uB3C4\uBC30\uC131 \uAD11\uACE0\uAC00 \uC788\uC73C\uBA74 \uBC18\uB4DC\uC2DC \uAC70\uBD80(flagged=true)\uD569\uB2C8\uB2E4.\n\nflagged=true \uC608:\n- \uC695\uC124/\uBE44\uC18D\uC5B4: \uC528\uBC1C, \uC2DC\uBC1C, \uBCD1\uC2E0, \uC9C0\uB784, \uC886, \uAC1C\uC0C8\uB07C, \uC874\uB098, fuck, shit, bitch \uB4F1\n- \uC131\uC801 \uBAA8\uC695, \uD610\uC624 \uBC1C\uC5B8, \uD611\uBC15\n- \uAC8C\uC2DC\uD310 \uC8FC\uC81C\uC640 \uBB34\uAD00\uD55C \uB3C4\uBC30\uC131 \uAD11\uACE0/\uC2A4\uD338\n\nflagged=false:\n- \uAC8C\uC2DC\uD310 \uC8FC\uC81C\uC5D0 \uB9DE\uB294 \uC815\uC0C1\uC801\uC778 \uAE00/\uB313\uAE00 (\uAC15\uD55C \uC758\uACAC\uC774\uB098 \uC815\uB2F9\uD55C \uBE44\uD310\uC740 \uD5C8\uC6A9)\n\nJSON\uB9CC \uCD9C\uB825:\n{\"flagged\":boolean,\"category\":\"none\"|\"abuse\"|\"spam\"|\"adult\"|\"violence\"|\"other\",\"reason\":string}\n- flagged=true\uBA74 category\uB97C \uACE0\uB974\uACE0, reason\uC740 \uD55C\uAD6D\uC5B4\uB85C **\uBB34\uC5C7\uC774 \uBB38\uC81C\uC778\uC9C0** \uAD6C\uCCB4\uC801\uC73C\uB85C 1\uBB38\uC7A5\n (\uC608: \"\uC695\uC124(\uBE44\uC18D\uC5B4)\uC774 \uD3EC\uD568\uB418\uC5B4 \uAC8C\uC2DC\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.\", \"\uAD11\uACE0\u00B7\uC2A4\uD338\uC131 \uBB38\uAD6C\uAC00 \uC788\uC5B4 \uAC8C\uC2DC\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.\")\n \uC695\uC124 \uB2E8\uC5B4 \uC804\uCCB4\uB97C \uADF8\uB300\uB85C \uAE38\uAC8C \uC778\uC6A9\uD558\uC9C0 \uB9D0\uACE0, \uC720\uD615(\uC695\uC124/\uD610\uC624/\uC2A4\uD338 \uB4F1)\uC744 \uBD84\uBA85\uD788 \uBC1D\uD78C\uB2E4.\n- flagged=false\uBA74 category\uB294 \"none\", reason\uC740 \"\"";
2
+ /** 사용자 안내용 카테고리 라벨 */
3
+ export declare const MODERATION_CATEGORY_LABELS: Record<string, string>;
4
+ /** 차단 사유를 사용자에게 읽기 쉽게 정리 */
5
+ export declare function formatModerationUserReason(opts: {
6
+ reason?: string;
7
+ category?: string;
8
+ via?: string;
9
+ localHit?: string;
10
+ }): string;
2
11
  /** API 없이도 1차로 걸러내는 한국어/영어 비속어 (부분 문자열 매칭) */
3
12
  export declare const DEFAULT_LOCAL_BLOCKLIST: string[];
4
13
  export declare function buildLocalBlockRegex(patterns: string[]): RegExp;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * 게시판 검열 차단 누적 → 일시 이용 제한 (호스트 contentWarning 과 동일 모델).
3
+ * - 콘텐츠 차단(로컬/AI flagged) 시 +1
4
+ * - 유효 경고 ≥ threshold(기본 3) → 글/댓글 작성 불가
5
+ * - 24h마다 lazy 차감 (moderationBlockAt 기준)
6
+ */
7
+ export declare const DEFAULT_BLOCK_BAN_THRESHOLD = 3;
8
+ export declare const DEFAULT_BLOCK_BAN_DECAY_MS: number;
9
+ export declare function effectiveModerationBlockCount(count: number | null | undefined, blockAtMs: number | null | undefined, nowMs?: number, decayMs?: number): number;
10
+ /** 제한 해제 시각(ms). 제한 없으면 null. */
11
+ export declare function moderationWriteRestrictedUntilMs(count: number | null | undefined, blockAtMs: number | null | undefined, nowMs?: number, threshold?: number, decayMs?: number): number | null;
12
+ export declare function isModerationWriteRestricted(count: number | null | undefined, blockAtMs: number | null | undefined, nowMs?: number, threshold?: number, decayMs?: number): boolean;
13
+ export declare function formatRestrictedUntilLabel(untilMs: number, nowMs?: number): string;
14
+ export declare function toBlockAtMs(value: unknown): number | null;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * 게시글 상세용 가벼운 마크다운 → HTML (TipTap 없이).
3
+ * 목록 이동 시 UEditor destroy/recreate 로 나는
4
+ * "Adding different instances of a keyed plugin" 를 피한다.
5
+ * 신뢰된 작성자 콘텐츠 전제 — 기본 이스케이프 후 허용 패턴만 복원.
6
+ */
7
+ /**
8
+ * GFM-ish 최소 변환. 코드 펜스·목록·제목·인용·hr·단락.
9
+ */
10
+ export declare function renderMarkdownToHtml(markdown: string): string;
@@ -0,0 +1,106 @@
1
+ # Firebase 프로젝트 준비 (처음 설치하는 경우)
2
+
3
+ 이 모듈을 처음 붙이는 프로젝트, 특히 Firebase를 아직 안 써본 프로젝트라면 README의 "설치" 섹션만으로는 부족하다. 여기 순서대로 하면 된다.
4
+
5
+ ## 1. 사전 준비
6
+
7
+ - Node.js + pnpm (또는 npm/yarn)
8
+ - Nuxt **4.0 이상**, `@nuxt/ui` **4.0 이상**
9
+ - Firebase CLI: `npm i -g firebase-tools` 후 `firebase login`
10
+
11
+ ## 2. Firebase 프로젝트 만들기 (아직 없다면)
12
+
13
+ 1. [Firebase 콘솔](https://console.firebase.google.com)에서 새 프로젝트를 만든다.
14
+ 2. **Firestore Database**를 사용 설정한다 (Native mode).
15
+ 3. **Storage**를 사용 설정한다.
16
+ 4. **Authentication**을 사용 설정하고, Sign-in method에서 쓸 provider(Google/Apple/이메일-비밀번호)를 켠다.
17
+
18
+ 이미 있는 Firebase 프로젝트에 붙이는 거라면 이 단계는 건너뛰고, 위 세 서비스가 켜져 있는지만 확인한다.
19
+
20
+ ## 3. `firebaseConfig` 값 가져오기
21
+
22
+ Firebase 콘솔 → 프로젝트 설정(톱니바퀴) → **일반** 탭 → "내 앱" 섹션 → 웹 앱이 없으면 추가 → "SDK 설정 및 구성"에서 `apiKey`/`authDomain`/`projectId`/`storageBucket`/`messagingSenderId`/`appId` 값을 그대로 복사해 `nuxt.config.ts`의 `vuefire.config`에 붙여넣는다(memi-board가 아니라 호스트의 nuxt-vuefire 설정이다 — README [설치](../README.md#설치) 참고).
23
+
24
+ ## 4. 호스트 프로젝트를 Firebase CLI에 연결
25
+
26
+ 프로젝트 루트에 아직 `firebase.json`/`.firebaserc`가 없다면:
27
+
28
+ ```bash
29
+ firebase init firestore storage
30
+ ```
31
+
32
+ 또는 수동으로 만든다:
33
+
34
+ ```json
35
+ // .firebaserc
36
+ { "projects": { "default": "<your-project-id>" } }
37
+ ```
38
+
39
+ ```json
40
+ // firebase.json
41
+ {
42
+ "firestore": { "rules": "firestore.rules" },
43
+ "storage": { "rules": "storage.rules" }
44
+ }
45
+ ```
46
+
47
+ 이미 Firebase CLI를 쓰고 있는 프로젝트라면, `firebase.json`에 `firestore.rules`/`storage.rules` 경로가 이미 지정돼 있는지만 확인하면 된다.
48
+
49
+ ## 5. 게시판 Security Rules 병합 + 배포
50
+
51
+ README의 [Security Model](../README.md#security-model--반드시-읽어야-하는-부분) 섹션대로 `docs/firestore.rules.example`/`docs/storage.rules.example`을 프로젝트 규칙 파일에 병합한 뒤:
52
+
53
+ ```bash
54
+ firebase deploy --only firestore:rules,storage
55
+ ```
56
+
57
+ **병합만 하고 배포를 안 하면 글쓰기·댓글에서 `permission-denied`가 난다** — 가장 흔한 설치 실수다.
58
+
59
+ ## 6. 로그인 공급자 설정
60
+
61
+ ### Google
62
+
63
+ Firebase 콘솔 → Authentication → Sign-in method에서 Google을 활성화하기만 하면 된다. 추가 설정 없음.
64
+
65
+ ### Apple
66
+
67
+ Google보다 손이 더 간다:
68
+
69
+ 1. Firebase 콘솔에서 Apple 로그인을 활성화한다.
70
+ 2. [Apple Developer](https://developer.apple.com/account) 계정에서 **Services ID**를 새로 만든다.
71
+ 3. 그 Services ID의 "Web Authentication Configuration"에 콜백 도메인을 등록한다 — Domains에 `{authDomain}`(Firebase 프로젝트의 authDomain, 보통 `<project-id>.firebaseapp.com`), Return URL에 `https://{authDomain}/__/auth/handler`.
72
+ 4. Apple Developer의 **Keys**에서 "Sign in with Apple" 키를 새로 만든다 (기존에 다른 용도 키가 있어도 별도로 새로 만들어야 한다).
73
+ 5. Team ID, 방금 만든 키의 Key ID, 그 키의 개인 키(`.p8` 파일 내용)를 Firebase 콘솔의 Apple 로그인 설정 화면에 입력한다.
74
+
75
+ 자세한 건 [Firebase 공식 가이드](https://firebase.google.com/docs/auth/web/apple)를 따른다. 이 설정 없이 Apple 로그인 버튼을 누르면 에러가 난다.
76
+
77
+ ## 7. Firebase AI Logic(Gemini) 활성화 — AI 검열용
78
+
79
+ 1. Firebase 콘솔 → **AI Logic** 에서 활성화하고, **Gemini Developer API** 를 사용하도록 설정한다.
80
+ 2. **요금제**: Spark에서도 되는 경우가 있으나 제한·쿼터가 있을 수 있다. 부족하면 Blaze.
81
+ 3. 모델: 호스트 `configureMemiBoard({ moderation: { model: 'gemini-3.5-flash-lite' } })` 권장.
82
+ `gemini-2.5-flash` 는 **신규 프로젝트에서 "no longer available to new users"** 로 404 날 수 있다.
83
+ 4. `moderation.enabled` 기본이 `true` 이므로 활성화 후 글쓰기 시 자동 호출된다. 끄려면 `enabled: false`.
84
+
85
+ ## 8. App Check 설정 (AI Logic Enforce 시 사실상 필수)
86
+
87
+ AI Logic / `firebaseml` 이 **Enforce** 이면 App Check 토큰 없이 검열 API가 실패한다. memi-board는 App Check를 초기화하지 않는다 — **호스트 책임**.
88
+
89
+ 1. Firebase 콘솔 → App Check → **웹 앱**에 reCAPTCHA v3 등록 (사이트 키·시크릿).
90
+ 등록하는 웹 앱의 **appId** 가 호스트 `vuefire.config.appId` 와 같아야 한다.
91
+ 2. 호스트 클라이언트 플러그인에서 `initializeAppCheck` + `ReCaptchaV3Provider` (README App Check 절 예시).
92
+ `vuefire.appCheck` 옵션만 쓰는 방법도 있으나, AI Logic 연동 이슈 시 명시적 `initializeAppCheck` 를 쓴다.
93
+ 3. **로컬:** `self.FIREBASE_APPCHECK_DEBUG_TOKEN = true`(콘솔에 UUID 출력 후 App Check → 디버그 토큰 등록) 또는 등록된 고정 UUID.
94
+ 4. **프로덕션:** 디버그 토큰 사용 금지. reCAPTCHA 도메인에 실제 호스트 도메인 등록.
95
+ 5. (선택) Firestore/Storage Enforce — 켜면 토큰 없는 요청 거부. AI Logic 과 별개 토글이다.
96
+
97
+ ## 9. Firestore 복합 인덱스 (카테고리 필터 사용 시)
98
+
99
+ `MemiBoardList` 에 `category` prop 을 넘기면 `category + createdAt` 복합 인덱스가 필요하다.
100
+ 콘솔 에러의 인덱스 생성 링크를 따르거나, 호스트 `firestore.indexes.json` 에 추가 후:
101
+
102
+ ```bash
103
+ firebase deploy --only firestore:indexes
104
+ ```
105
+
106
+ 컬렉션 ID 예: `collectionPrefix` 가 `board` 이면 `boardPosts`.