memi-board 0.4.6 → 0.4.13

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,56 +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
- /** 제출 중 안내 (검토 → 저장) */
46
51
  const submitHint = ref('')
47
52
  const error = ref('')
53
+ const imageUploading = ref(false)
54
+ const imageUploadError = ref('')
48
55
 
49
56
  const showAddCategory = ref(false)
50
57
  const newCategoryLabel = ref('')
51
58
  const addingCategory = ref(false)
52
59
 
53
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
+ }
54
189
 
55
190
  async function loadPost(id: string) {
56
191
  loading.value = true
@@ -141,7 +276,7 @@ function friendlyWriteError(e: unknown): string {
141
276
 
142
277
  async function handleSubmit() {
143
278
  error.value = ''
144
- if (!title.value.trim() || !content.value.trim()) {
279
+ if (!title.value.trim() || isContentEmpty(content.value)) {
145
280
  error.value = '제목과 내용을 입력해 주세요.'
146
281
  return
147
282
  }
@@ -158,13 +293,17 @@ async function handleSubmit() {
158
293
  error.value = '목록에 있는 카테고리를 선택해 주세요.'
159
294
  return
160
295
  }
296
+ if (isWriteRestricted.value) {
297
+ error.value = restrictedMessage.value
298
+ || '콘텐츠 경고가 누적되어 글·댓글 작성이 잠시 제한됐어요.'
299
+ return
300
+ }
161
301
 
162
302
  saving.value = true
163
303
  submitHint.value = '내용을 검토하는 중…'
164
304
  try {
165
- const text = `${title.value}\n${content.value}`
166
- const moderation = await checkText(text)
167
-
305
+ const plain = content.value.replace(/<[^>]+>/g, ' ')
306
+ const moderation = await checkText(`${title.value}\n${plain}`)
168
307
  if (moderation.flagged) {
169
308
  error.value = moderation.reason || '게시할 수 없는 내용이 포함되어 있습니다.'
170
309
  return
@@ -218,7 +357,6 @@ async function handleSubmit() {
218
357
  class="flex flex-col gap-4"
219
358
  @submit.prevent="handleSubmit"
220
359
  >
221
- <!-- path 로 카테고리 고정 시: 표시만 / 아니면 선택+추가 -->
222
360
  <div
223
361
  v-if="categoryLocked"
224
362
  class="flex items-center gap-2 text-sm"
@@ -286,12 +424,54 @@ async function handleSubmit() {
286
424
  size="lg"
287
425
  required
288
426
  />
289
- <UTextarea
290
- v-model="content"
291
- placeholder="내용을 입력하세요"
292
- :rows="12"
293
- required
294
- />
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
+
295
475
  <UInput
296
476
  v-model="tagsInput"
297
477
  placeholder="태그 (쉼표로 구분)"
@@ -303,6 +483,12 @@ async function handleSubmit() {
303
483
  editable
304
484
  />
305
485
 
486
+ <p
487
+ v-if="isWriteRestricted && restrictedMessage"
488
+ class="text-sm text-warning"
489
+ >
490
+ {{ restrictedMessage }}
491
+ </p>
306
492
  <p
307
493
  v-if="error"
308
494
  class="text-sm text-error"
@@ -320,9 +506,11 @@ async function handleSubmit() {
320
506
  <UButton
321
507
  type="submit"
322
508
  :loading="saving"
509
+ :disabled="isWriteRestricted || imageUploading"
323
510
  :label="isEdit ? '수정 완료' : '게시하기'"
324
511
  />
325
512
  <UButton
513
+ type="button"
326
514
  variant="ghost"
327
515
  color="neutral"
328
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 {
@@ -54,7 +66,7 @@ export interface BoardSettingsModel {
54
66
  updatedAt?: Timestamp;
55
67
  }
56
68
  /** 검열이 어느 단계에서 끝났는지 (호스트/로깅용, UI 노출 없음) */
57
- export type ModerationVia = 'empty' | 'disabled' | 'local' | 'ai' | 'ai-error-allow' | 'ai-error-block';
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';
@@ -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,7 @@
1
+ /**
2
+ * Firebase Storage download URL → object path.
3
+ * 예: .../o/board%2Fposts%2Fid%2Fimages%2Fx.png?alt=media → board/posts/id/images/x.png
4
+ */
5
+ export declare function storagePathFromDownloadUrl(url: string): string | null;
6
+ /** path 에서 posts/{ns}/... 의 ns 추출 */
7
+ export declare function postNamespaceFromStoragePath(path: string): string | null;
@@ -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`.
@@ -0,0 +1,97 @@
1
+ // memi-board Firestore Security Rules 예시
2
+ //
3
+ // 이 모듈은 서버가 없는 클라이언트 전용 아키텍처이므로, 모든 권한 검증은 여기 규칙이 전부다.
4
+ // 호스트 프로젝트의 firestore.rules에 아래 내용을 병합해서 배포해야 게시판이 실제로 안전해진다.
5
+ //
6
+ // collectionPrefix 를 'memiBoard'가 아닌 다른 값(예: board)으로 쓰면
7
+ // 아래 memiBoardPosts / memiBoardUsers / memiBoardSettings 이름을
8
+ // {prefix}Posts / {prefix}Users / {prefix}Settings 로 전부 바꿔야 한다.
9
+ // 예: board → boardPosts, boardUsers, boardSettings
10
+
11
+ rules_version = '2';
12
+ service cloud.firestore {
13
+ match /databases/{database}/documents {
14
+
15
+ function isSignedIn() {
16
+ return request.auth != null;
17
+ }
18
+ function isAdmin() {
19
+ return isSignedIn() &&
20
+ get(/databases/$(database)/documents/memiBoardUsers/$(request.auth.uid)).data.role == 'admin';
21
+ }
22
+ function isOwner(resource) {
23
+ return isSignedIn() && request.auth.uid == resource.data.authorUid;
24
+ }
25
+
26
+ match /memiBoardPosts/{postId} {
27
+ allow read: if true;
28
+
29
+ allow create: if isSignedIn()
30
+ && request.resource.data.authorUid == request.auth.uid
31
+ && request.resource.data.moderationStatus == 'approved';
32
+
33
+ // 작성자 본인 또는 관리자만 수정/삭제 가능
34
+ allow update, delete: if isOwner(resource) || isAdmin();
35
+
36
+ match /body/{docId} {
37
+ allow read: if true;
38
+ allow write: if isOwner(get(/databases/$(database)/documents/memiBoardPosts/$(postId))) || isAdmin();
39
+ }
40
+
41
+ match /comments/{commentId} {
42
+ allow read: if true;
43
+
44
+ allow create: if isSignedIn()
45
+ && request.resource.data.authorUid == request.auth.uid
46
+ && request.resource.data.moderationStatus == 'approved';
47
+
48
+ // 댓글 작성자·부모 게시글 작성자·게시판 관리자 모두 삭제 가능.
49
+ // 부모 글을 삭제할 때 작성자가 다른 사용자의 댓글도 함께 정리할 수 있어야 한다.
50
+ allow delete: if isOwner(resource)
51
+ || isOwner(get(/databases/$(database)/documents/memiBoardPosts/$(postId)))
52
+ || isAdmin();
53
+
54
+ // 댓글 수정은 지원하지 않는다 (삭제 후 재작성만 가능) — Editor에서 UI 자체도 제공하지 않음
55
+ allow update: if false;
56
+ }
57
+ }
58
+
59
+ // 게시판 역할 문서.
60
+ // 본인은 role:'user'로만 생성/유지할 수 있고, 자기 role을 'admin'으로 바꿀 수 없다.
61
+ // 관리자 승격은 Firebase 콘솔에서 해당 uid 문서의 role 필드를 직접 'admin'으로 바꿔야 한다
62
+ // (이 모듈은 관리자 승격 UI를 제공하지 않는다).
63
+ // moderationBlockCount/At: 검열 차단 누적(+1만). 감소·리셋은 admin.
64
+ match /memiBoardUsers/{uid} {
65
+ allow read: if isSignedIn();
66
+ allow create: if isSignedIn()
67
+ && request.auth.uid == uid
68
+ && request.resource.data.role == 'user'
69
+ && (!('moderationBlockCount' in request.resource.data)
70
+ || request.resource.data.moderationBlockCount == 0
71
+ || request.resource.data.moderationBlockCount == 1);
72
+ allow update: if isAdmin()
73
+ || (isSignedIn()
74
+ && request.auth.uid == uid
75
+ && request.resource.data.role == resource.data.role
76
+ && (
77
+ !request.resource.data.diff(resource.data).affectedKeys()
78
+ .hasAny(['moderationBlockCount', 'moderationBlockAt'])
79
+ || (
80
+ request.resource.data.diff(resource.data).affectedKeys()
81
+ .hasOnly(['moderationBlockCount', 'moderationBlockAt', 'updatedAt'])
82
+ && request.resource.data.moderationBlockCount
83
+ == resource.data.get('moderationBlockCount', 0) + 1
84
+ )
85
+ ));
86
+ }
87
+
88
+ // 게시판 설정(카테고리 등) — 읽기 전체 공개.
89
+ // 로그인 사용자가 글쓰기 폼에서 카테고리를 추가할 수 있다 (categories + updatedAt 만).
90
+ match /memiBoardSettings/{docId} {
91
+ allow read: if true;
92
+ allow create, update: if isSignedIn()
93
+ && request.resource.data.keys().hasOnly(['categories', 'updatedAt'])
94
+ && request.resource.data.categories is list;
95
+ }
96
+ }
97
+ }