memi-board 0.30.2 → 0.32.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.
@@ -9,6 +9,7 @@
9
9
  import { ref, computed, watch } from 'vue'
10
10
  import Youtube from '@tiptap/extension-youtube'
11
11
  import type { Attachment, EditorImageEntry } from 'memi-board/runtime'
12
+ import type { WritingAssistantAction } from 'memi-board/runtime'
12
13
  import {
13
14
  useMemiBoardAuth,
14
15
  useMemiBoardPosts,
@@ -17,6 +18,7 @@ import {
17
18
  hasBodyImage,
18
19
  hasBodyText,
19
20
  plainTextFromHtml,
21
+ useMemiBoardWritingAssistant,
20
22
  } from 'memi-board/runtime'
21
23
  import { useMemiBoardStorage, EDITOR_IMAGE_SOURCE_MAX_BYTES } from 'memi-board/storage'
22
24
  import MemiBoardAttachments from './Attachments.vue'
@@ -37,6 +39,7 @@ const { createPostId, getPost, createPost, updatePost } = useMemiBoardPosts(reso
37
39
  const { checkText } = useMemiBoardModeration()
38
40
  const { getBoard, ensureSettings } = useMemiBoardSettings()
39
41
  const { uploadEditorImage } = useMemiBoardStorage()
42
+ const { assist } = useMemiBoardWritingAssistant()
40
43
 
41
44
  const title = ref('')
42
45
  /** UEditor content-type=html (shineb 와 동일) */
@@ -54,6 +57,20 @@ const submitHint = ref('')
54
57
  const error = ref('')
55
58
  const imageUploading = ref(false)
56
59
  const imageUploadError = ref('')
60
+ const imageDialogOpen = ref(false)
61
+ const imageDialogEditor = ref<any | null>(null)
62
+ const externalImageUrl = ref('')
63
+ const externalImageChecking = ref(false)
64
+ const externalImageCandidate = computed(() => {
65
+ try {
66
+ return normalizeExternalImageUrl(externalImageUrl.value)
67
+ }
68
+ catch {
69
+ return ''
70
+ }
71
+ })
72
+ const aiRunning = ref(false)
73
+ const aiError = ref('')
57
74
 
58
75
  const attachmentNamespace = ref(props.postId ?? createPostId())
59
76
  const uploadedEditorImages = ref<EditorImageEntry[]>([])
@@ -147,6 +164,87 @@ function appendCoverUrls(urls: string[]) {
147
164
  }
148
165
  }
149
166
 
167
+ function normalizeExternalImageUrl(value: string): string {
168
+ const raw = value.trim()
169
+ if (!raw) throw new Error('이미지 주소를 입력해 주세요.')
170
+ let url: URL
171
+ try {
172
+ url = new URL(raw)
173
+ }
174
+ catch {
175
+ throw new Error('올바른 이미지 주소를 입력해 주세요.')
176
+ }
177
+ if (url.protocol !== 'https:') {
178
+ throw new Error('HTTPS 이미지 주소만 사용할 수 있습니다.')
179
+ }
180
+ if (url.username || url.password) {
181
+ throw new Error('로그인 정보가 포함된 주소는 사용할 수 없습니다.')
182
+ }
183
+ return url.toString()
184
+ }
185
+
186
+ function verifyExternalImage(url: string): Promise<string> {
187
+ return new Promise((resolve, reject) => {
188
+ const image = new Image()
189
+ const timeout = window.setTimeout(() => {
190
+ image.src = ''
191
+ reject(new Error('이미지를 불러오는 데 시간이 너무 오래 걸립니다.'))
192
+ }, 10_000)
193
+ image.onload = () => {
194
+ window.clearTimeout(timeout)
195
+ if (!image.naturalWidth || !image.naturalHeight) {
196
+ reject(new Error('표시할 수 없는 이미지입니다.'))
197
+ return
198
+ }
199
+ resolve(url)
200
+ }
201
+ image.onerror = () => {
202
+ window.clearTimeout(timeout)
203
+ reject(new Error('이미지를 불러올 수 없습니다. 주소나 외부 링크 허용 여부를 확인해 주세요.'))
204
+ }
205
+ image.referrerPolicy = 'no-referrer'
206
+ image.src = url
207
+ })
208
+ }
209
+
210
+ function openImageDialog(editor?: any) {
211
+ imageDialogEditor.value = editor ?? resolveEditor()
212
+ externalImageUrl.value = ''
213
+ imageUploadError.value = ''
214
+ imageDialogOpen.value = true
215
+ }
216
+
217
+ function chooseImageFileFromDialog() {
218
+ const editor = imageDialogEditor.value ?? resolveEditor()
219
+ imageDialogOpen.value = false
220
+ if (isImageListView.value) pickCoverImages()
221
+ else if (editor) pickAndUploadImage(editor)
222
+ }
223
+
224
+ async function addExternalImage() {
225
+ if (externalImageChecking.value) return
226
+ imageUploadError.value = ''
227
+ externalImageChecking.value = true
228
+ try {
229
+ const url = await verifyExternalImage(normalizeExternalImageUrl(externalImageUrl.value))
230
+ if (isImageListView.value) {
231
+ appendCoverUrls([url])
232
+ }
233
+ else {
234
+ const editor = imageDialogEditor.value ?? resolveEditor()
235
+ if (!editor) throw new Error('에디터를 준비하지 못했습니다. 다시 시도해 주세요.')
236
+ editor.chain().focus().setImage({ src: url }).run()
237
+ }
238
+ imageDialogOpen.value = false
239
+ }
240
+ catch (cause) {
241
+ imageUploadError.value = cause instanceof Error ? cause.message : '이미지 링크를 추가하지 못했습니다.'
242
+ }
243
+ finally {
244
+ externalImageChecking.value = false
245
+ }
246
+ }
247
+
150
248
  function removeCoverAt(index: number) {
151
249
  coverSlots.value = coverSlots.value.filter((_, i) => i !== index)
152
250
  imageUploadError.value = ''
@@ -363,7 +461,7 @@ function insertYoutube(editor: any, value?: string | null): boolean {
363
461
  return editor.chain().focus().setYoutubeVideo({ src }).run()
364
462
  }
365
463
 
366
- /** 공식 handlers API — 기본 image(URL prompt) 를 파일 업로드로 교체 (shineb) */
464
+ /** 공식 handlers API — 파일 업로드 또는 외부 HTTPS 이미지 링크 */
367
465
  const handlers = computed(() => ({
368
466
  image: {
369
467
  canExecute: (editor: any) =>
@@ -372,7 +470,7 @@ const handlers = computed(() => ({
372
470
  isDisabled: () => isImageListView.value,
373
471
  execute: (editor: any) => {
374
472
  if (isImageListView.value) return editor.chain()
375
- pickAndUploadImage(editor)
473
+ openImageDialog(editor)
376
474
  return editor.chain()
377
475
  },
378
476
  },
@@ -439,11 +537,72 @@ const toolbarItems = computed(() => {
439
537
  ]
440
538
  })
441
539
 
540
+ const aiMenuItems = computed(() => [
541
+ [
542
+ { label: '맞춤법 검사', icon: 'i-lucide-spell-check-2', onSelect: () => runWritingAssistant('proofread') },
543
+ { label: '문장 다듬기', icon: 'i-lucide-wand-sparkles', onSelect: () => runWritingAssistant('polish') },
544
+ { label: '자연스럽게 이어쓰기', icon: 'i-lucide-text-cursor-input', onSelect: () => runWritingAssistant('continue') },
545
+ { label: '짧게 요약하기', icon: 'i-lucide-scan-text', onSelect: () => runWritingAssistant('summarize') },
546
+ ],
547
+ [
548
+ { label: '영어로 번역', icon: 'i-lucide-languages', onSelect: () => runWritingAssistant('translate-en') },
549
+ { label: '한국어로 번역', icon: 'i-lucide-languages', onSelect: () => runWritingAssistant('translate-ko') },
550
+ ],
551
+ [
552
+ { label: '본문에서 제목 만들기', icon: 'i-lucide-heading-1', onSelect: () => runWritingAssistant('title') },
553
+ ],
554
+ ])
555
+
442
556
  function resolveEditor(): any | null {
443
557
  const ed = (editorRef.value as any)?.editor
444
558
  return ed?.value ?? ed ?? null
445
559
  }
446
560
 
561
+ async function runWritingAssistant(action: WritingAssistantAction) {
562
+ const editor = resolveEditor()
563
+ if (!editor || aiRunning.value) return
564
+ aiError.value = ''
565
+
566
+ const { from, to } = editor.state.selection
567
+ const hasSelection = from !== to
568
+ const selectedText = hasSelection ? editor.state.doc.textBetween(from, to, '\n') : ''
569
+ const source = action === 'title'
570
+ ? plainTextFromHtml(content.value)
571
+ : hasSelection ? selectedText : content.value
572
+
573
+ aiRunning.value = true
574
+ try {
575
+ const result = await assist({
576
+ action,
577
+ content: source,
578
+ title: title.value,
579
+ selection: hasSelection || action === 'title',
580
+ })
581
+
582
+ if (action === 'title') {
583
+ title.value = result.replace(/^['"“”]|['"“”]$/g, '').trim()
584
+ }
585
+ else if (action === 'continue') {
586
+ const position = hasSelection ? to : editor.state.doc.content.size
587
+ editor.chain().focus().insertContentAt(position, result).run()
588
+ }
589
+ else if (hasSelection) {
590
+ editor.chain().focus().insertContentAt({ from, to }, result).run()
591
+ }
592
+ else {
593
+ editor.commands.setContent(result, { emitUpdate: true })
594
+ editor.chain().focus('end').run()
595
+ }
596
+ }
597
+ catch (cause) {
598
+ console.warn('[memi-board] writing assistant failed', cause)
599
+ aiError.value = cause instanceof Error ? cause.message : 'AI 작업에 실패했습니다. 다시 시도해 주세요.'
600
+ }
601
+ finally {
602
+ aiRunning.value = false
603
+ }
604
+ }
605
+
447
606
  // 제목에서 Tab 누르면 툴바 버튼들 건너뛰고 본문으로 바로 이동
448
607
  function focusContentFromTitle() {
449
608
  resolveEditor()?.chain().focus().run()
@@ -746,17 +905,29 @@ async function handleSubmit() {
746
905
  <p class="text-xs text-muted">
747
906
  사진 {{ coverSlots.length }}/{{ COVER_IMAGE_MAX }} · 끌어 순서 변경 · 파일 드롭/붙여넣기로 추가
748
907
  </p>
749
- <UButton
750
- v-if="coverSlots.length > 0"
751
- type="button"
752
- size="xs"
753
- color="neutral"
754
- variant="ghost"
755
- label="사진 추가"
756
- icon="i-lucide-plus"
757
- :disabled="imageUploading || coverSlots.length >= COVER_IMAGE_MAX"
758
- @click="pickCoverImages"
759
- />
908
+ <div class="flex shrink-0 items-center gap-1">
909
+ <UButton
910
+ type="button"
911
+ size="xs"
912
+ color="neutral"
913
+ variant="ghost"
914
+ label="이미지 링크"
915
+ icon="i-lucide-link"
916
+ :disabled="imageUploading || coverSlots.length >= COVER_IMAGE_MAX"
917
+ @click="openImageDialog()"
918
+ />
919
+ <UButton
920
+ v-if="coverSlots.length > 0"
921
+ type="button"
922
+ size="xs"
923
+ color="neutral"
924
+ variant="ghost"
925
+ label="사진 추가"
926
+ icon="i-lucide-plus"
927
+ :disabled="imageUploading || coverSlots.length >= COVER_IMAGE_MAX"
928
+ @click="pickCoverImages"
929
+ />
930
+ </div>
760
931
  </div>
761
932
 
762
933
  <div
@@ -892,15 +1063,38 @@ async function handleSubmit() {
892
1063
  class="board-content w-full"
893
1064
  >
894
1065
  <template #default="{ editor }">
895
- <UEditorToolbar
896
- :editor="editor"
897
- :items="toolbarItems"
898
- class="border-b border-default p-2 flex-wrap"
899
- />
1066
+ <div class="flex items-start border-b border-default">
1067
+ <UEditorToolbar
1068
+ :editor="editor"
1069
+ :items="toolbarItems"
1070
+ class="min-w-0 flex-1 p-2 flex-wrap"
1071
+ />
1072
+ <UDropdownMenu :items="aiMenuItems">
1073
+ <UButton
1074
+ icon="i-lucide-sparkles"
1075
+ label="AI"
1076
+ color="neutral"
1077
+ variant="ghost"
1078
+ size="sm"
1079
+ class="m-2 shrink-0"
1080
+ :loading="aiRunning"
1081
+ :disabled="aiRunning"
1082
+ aria-label="AI 글쓰기 도우미"
1083
+ />
1084
+ </UDropdownMenu>
1085
+ </div>
900
1086
  <UEditorDragHandle :editor="editor" />
901
1087
  </template>
902
1088
  </UEditor>
903
1089
 
1090
+ <p
1091
+ v-if="aiError"
1092
+ class="flex items-center gap-2 border-t border-default px-4 py-2 text-xs text-error"
1093
+ >
1094
+ <UIcon name="i-lucide-circle-alert" class="size-3.5 shrink-0" />
1095
+ {{ aiError }}
1096
+ </p>
1097
+
904
1098
  <p
905
1099
  v-if="imageUploading && !isImageListView"
906
1100
  class="flex items-center gap-2 border-t border-default px-4 py-2 text-xs text-muted"
@@ -963,6 +1157,91 @@ async function handleSubmit() {
963
1157
  />
964
1158
  </div>
965
1159
  </form>
1160
+
1161
+ <UModal
1162
+ v-model:open="imageDialogOpen"
1163
+ title="이미지 추가"
1164
+ :ui="{ content: 'sm:max-w-lg' }"
1165
+ >
1166
+ <template #body>
1167
+ <div class="flex flex-col gap-4">
1168
+ <UButton
1169
+ type="button"
1170
+ color="neutral"
1171
+ variant="outline"
1172
+ icon="i-lucide-upload"
1173
+ label="내 기기에서 이미지 선택"
1174
+ block
1175
+ :disabled="imageUploading"
1176
+ @click="chooseImageFileFromDialog"
1177
+ />
1178
+
1179
+ <div class="flex items-center gap-3 text-xs text-muted">
1180
+ <span class="h-px flex-1 bg-default" />
1181
+ 또는 이미지 링크
1182
+ <span class="h-px flex-1 bg-default" />
1183
+ </div>
1184
+
1185
+ <UFormField
1186
+ label="이미지 URL"
1187
+ help="HTTPS 주소만 사용할 수 있으며 원본 사이트에서 삭제되면 이미지도 표시되지 않습니다."
1188
+ >
1189
+ <UInput
1190
+ v-model="externalImageUrl"
1191
+ type="url"
1192
+ inputmode="url"
1193
+ placeholder="https://example.com/image.jpg"
1194
+ autocomplete="off"
1195
+ class="w-full"
1196
+ @keyup.enter="addExternalImage"
1197
+ />
1198
+ </UFormField>
1199
+
1200
+ <div
1201
+ v-if="externalImageCandidate"
1202
+ class="flex min-h-32 items-center justify-center overflow-hidden rounded-lg border border-default bg-elevated/30"
1203
+ >
1204
+ <img
1205
+ :src="externalImageCandidate"
1206
+ alt="외부 이미지 미리보기"
1207
+ referrerpolicy="no-referrer"
1208
+ class="max-h-64 max-w-full object-contain"
1209
+ >
1210
+ </div>
1211
+
1212
+ <UAlert
1213
+ v-if="imageUploadError"
1214
+ color="error"
1215
+ variant="subtle"
1216
+ icon="i-lucide-circle-alert"
1217
+ :description="imageUploadError"
1218
+ />
1219
+ </div>
1220
+ </template>
1221
+
1222
+ <template #footer>
1223
+ <div class="flex w-full justify-end gap-2">
1224
+ <UButton
1225
+ type="button"
1226
+ color="neutral"
1227
+ variant="ghost"
1228
+ :disabled="externalImageChecking"
1229
+ @click="imageDialogOpen = false"
1230
+ >
1231
+ 취소
1232
+ </UButton>
1233
+ <UButton
1234
+ type="button"
1235
+ icon="i-lucide-link"
1236
+ :loading="externalImageChecking"
1237
+ :disabled="!externalImageCandidate"
1238
+ @click="addExternalImage"
1239
+ >
1240
+ 링크 추가
1241
+ </UButton>
1242
+ </div>
1243
+ </template>
1244
+ </UModal>
966
1245
  </template>
967
1246
 
968
1247
  <style scoped>
@@ -69,6 +69,7 @@ const draft = ref<BoardCategory[]>([])
69
69
  const newLabel = ref('')
70
70
  const savingId = ref<string | null>(null)
71
71
  const savedId = ref<string | null>(null)
72
+ const deletingId = ref<string | null>(null)
72
73
  const ordering = ref(false)
73
74
  const error = ref('')
74
75
  const deleteConfirm = ref('')
@@ -132,14 +133,20 @@ async function moveCategory(index: number, offset: -1 | 1) {
132
133
 
133
134
  async function removeCategory(index: number) {
134
135
  const category = draft.value[index]
135
- if (!category || !window.confirm(`‘${category.label}’ 카테고리를 삭제하시겠습니까?`)) return
136
+ if (!category || !window.confirm(
137
+ `‘${category.label}’ 게시판을 삭제하시겠습니까?\n\n이 게시판의 모든 게시물, 댓글, 대댓글, 본문 이미지와 첨부파일이 영구 삭제됩니다. 이 작업은 되돌릴 수 없습니다.`,
138
+ )) return
136
139
  error.value = ''
140
+ deletingId.value = category.id
137
141
  try {
138
142
  await deleteCategory(category.id)
139
143
  draft.value.splice(index, 1)
140
144
  }
141
145
  catch (cause) {
142
- error.value = cause instanceof Error ? cause.message : '카테고리를 삭제하지 못했습니다.'
146
+ error.value = cause instanceof Error ? cause.message : '게시판과 연결 데이터를 삭제하지 못했습니다.'
147
+ }
148
+ finally {
149
+ deletingId.value = null
143
150
  }
144
151
  }
145
152
 
@@ -299,7 +306,17 @@ async function runDeleteAll() {
299
306
  size="sm"
300
307
  :to="categoryTo(category.id)"
301
308
  />
302
- <UButton v-if="!categoryId" label="카테고리 삭제" icon="i-lucide-trash-2" color="error" variant="ghost" size="sm" @click="removeCategory(index)" />
309
+ <UButton
310
+ v-if="!categoryId"
311
+ label="게시판 삭제"
312
+ icon="i-lucide-trash-2"
313
+ color="error"
314
+ variant="ghost"
315
+ size="sm"
316
+ :loading="deletingId === category.id"
317
+ :disabled="deletingId !== null"
318
+ @click="removeCategory(index)"
319
+ />
303
320
  <UButton
304
321
  label="저장"
305
322
  icon="i-lucide-save"
@@ -86,7 +86,7 @@ function F() {
86
86
  onError: "allow",
87
87
  moderateImages: !1,
88
88
  localBlocklist: [],
89
- blockBanThreshold: 3,
89
+ blockBanThreshold: 10,
90
90
  blockBanDecayMs: 864e5
91
91
  },
92
92
  seo: {
@@ -119,4 +119,4 @@ function B(e, t = 60) {
119
119
  //#endregion
120
120
  export { M as C, y as D, v as E, l as O, x as S, _ as T, C as _, L as a, T as b, f as c, j as d, N as f, w as g, O as h, R as i, d as k, h as l, A as m, z as n, p as o, k as p, I as r, m as s, B as t, g as u, D as v, b as w, S as x, E as y };
121
121
 
122
- //# sourceMappingURL=slugify-CDMIDDCN.js.map
122
+ //# sourceMappingURL=slugify-B5XHr4SZ.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"slugify-CDMIDDCN.js","names":[],"sources":["../src/utils/boardPaths.ts","../src/config.ts","../src/utils/slugify.ts"],"sourcesContent":["/**\n * Firestore / Storage 경로\n *\n * ```\n * memiBoardUsers/{uid}\n * memiBoardSettings/{boardId} // 보드 설정 (구 memiBoards/{id} stub + settings/config 통합)\n * memiBoardPosts/{postId} // boardId 는 문서 필드\n * memiBoardPosts/{postId}/body/main\n * memiBoardComments/{commentId} // postId, boardId 는 문서 필드\n * memiBoardLikes/{postId}_{uid} // uid, postId, boardId 는 문서 필드\n * ```\n *\n * Storage: `memiBoardPosts/{postId}/...` (postId 가 전역 고유이므로 boardId 불필요)\n *\n * 카테고리 개념 없음 — 예전 category id 가 곧 boardId. boardId/postId 는 경로가\n * 아니라 문서 필드이므로, 보드/글 단위로 좁혀야 하는 쿼리는 항상 where 필터로\n * 스코핑한다 (컬렉션 자체는 항상 최상위 flat 컬렉션).\n */\nimport {\n collection,\n doc,\n type CollectionReference,\n type DocumentReference,\n type Firestore,\n} from 'firebase/firestore'\n\nexport const DEFAULT_POSTS_COLLECTION = 'memiBoardPosts'\nexport const DEFAULT_COMMENTS_COLLECTION = 'memiBoardComments'\nexport const DEFAULT_LIKES_COLLECTION = 'memiBoardLikes'\nexport const DEFAULT_SETTINGS_COLLECTION = 'memiBoardSettings'\nexport const DEFAULT_USERS_COLLECTION = 'memiBoardUsers'\n\nexport interface BoardPathConfig {\n postsCollection: string\n commentsCollection: string\n likesCollection: string\n settingsCollection: string\n usersCollection: string\n}\n\nexport function resolveBoardPathConfig(input: {\n postsCollection?: string\n commentsCollection?: string\n likesCollection?: string\n settingsCollection?: string\n usersCollection?: string\n}): BoardPathConfig {\n return {\n postsCollection: input.postsCollection?.trim() || DEFAULT_POSTS_COLLECTION,\n commentsCollection: input.commentsCollection?.trim() || DEFAULT_COMMENTS_COLLECTION,\n likesCollection: input.likesCollection?.trim() || DEFAULT_LIKES_COLLECTION,\n settingsCollection: input.settingsCollection?.trim() || DEFAULT_SETTINGS_COLLECTION,\n usersCollection: input.usersCollection?.trim() || DEFAULT_USERS_COLLECTION,\n }\n}\n\n/** 보드 설정 목록/문서 — 구 memiBoards/{boardId} stub + settings/config 통합 */\nexport function settingsCol(db: Firestore, cfg: BoardPathConfig): CollectionReference {\n return collection(db, cfg.settingsCollection)\n}\n\nexport function settingsDoc(\n db: Firestore,\n cfg: BoardPathConfig,\n boardId: string,\n): DocumentReference {\n return doc(db, cfg.settingsCollection, boardId)\n}\n\nexport function postsCol(db: Firestore, cfg: BoardPathConfig): CollectionReference {\n return collection(db, cfg.postsCollection)\n}\n\nexport function postDoc(db: Firestore, cfg: BoardPathConfig, postId: string): DocumentReference {\n return doc(db, cfg.postsCollection, postId)\n}\n\nexport function postBodyDoc(\n db: Firestore,\n cfg: BoardPathConfig,\n postId: string,\n): DocumentReference {\n return doc(db, cfg.postsCollection, postId, 'body', 'main')\n}\n\nexport function commentsCol(db: Firestore, cfg: BoardPathConfig): CollectionReference {\n return collection(db, cfg.commentsCollection)\n}\n\nexport function commentDoc(\n db: Firestore,\n cfg: BoardPathConfig,\n commentId: string,\n): DocumentReference {\n return doc(db, cfg.commentsCollection, commentId)\n}\n\nexport function likesCol(db: Firestore, cfg: BoardPathConfig): CollectionReference {\n return collection(db, cfg.likesCollection)\n}\n\n/** 좋아요 문서ID 관례: `${postId}_${uid}` — 유저당 글당 좋아요 1개, 토글은 create-or-delete */\nexport function likeDocId(postId: string, uid: string): string {\n return `${postId}_${uid}`\n}\n\nexport function likeDoc(\n db: Firestore,\n cfg: BoardPathConfig,\n postId: string,\n uid: string,\n): DocumentReference {\n return doc(db, cfg.likesCollection, likeDocId(postId, uid))\n}\n\nexport function boardUsersCol(db: Firestore, cfg: BoardPathConfig): CollectionReference {\n return collection(db, cfg.usersCollection)\n}\n\nexport function boardUserDoc(\n db: Firestore,\n cfg: BoardPathConfig,\n uid: string,\n): DocumentReference {\n return doc(db, cfg.usersCollection, uid)\n}\n\n/** 이메일만 담는 비공개 문서 — memiBoardUsers/{uid}는 공개 프로필이라 이메일만 분리한다. */\nexport function boardUserPrivateDoc(\n db: Firestore,\n cfg: BoardPathConfig,\n uid: string,\n): DocumentReference {\n return doc(db, cfg.usersCollection, uid, 'private', 'info')\n}\n\n/** 본인만 보는 작성자 메모 — 문서 ID 자체가 targetUid라 대상당 메모가 하나로 고정된다. */\nexport function authorMemoDoc(\n db: Firestore,\n cfg: BoardPathConfig,\n viewerUid: string,\n targetUid: string,\n): DocumentReference {\n return doc(db, cfg.usersCollection, viewerUid, 'memos', targetUid)\n}\n\n/** postId 가 전역 고유이므로 boardId 프리픽스 불필요 */\nexport function postStorageFolder(cfg: BoardPathConfig, postId: string): string {\n return `${cfg.postsCollection}/${postId}`\n}\n\nexport function boardSsrKey(cfg: BoardPathConfig, suffix: string): string {\n return `${cfg.postsCollection}/${cfg.usersCollection}/${suffix}`\n}\n","import { reactive } from 'vue'\nimport {\n DEFAULT_COMMENTS_COLLECTION,\n DEFAULT_LIKES_COLLECTION,\n DEFAULT_POSTS_COLLECTION,\n DEFAULT_SETTINGS_COLLECTION,\n DEFAULT_USERS_COLLECTION,\n resolveBoardPathConfig,\n type BoardPathConfig,\n} from './utils/boardPaths'\n\nexport interface MemiBoardAuthOptions {\n providers?: Array<'google' | 'apple' | 'emailPassword'>\n}\n\nexport interface MemiBoardModerationOptions {\n enabled?: boolean\n model?: string\n localBlocklist?: string[]\n onError?: 'allow' | 'block'\n moderateImages?: boolean\n useLimitedUseAppCheckTokens?: boolean\n blockBanThreshold?: number\n blockBanDecayMs?: number\n}\n\nexport interface MemiBoardSeoOptions {\n enabled?: boolean\n siteName?: string\n siteUrl?: string\n defaultOgImage?: string\n /**\n * 호스트 게시판 base path (trailing slash 없음). 기본 '/board'\n * canonical: {base}, {base}/{boardId}, {base}/{boardId}/{slug}\n */\n basePath?: string\n}\n\nexport interface MemiBoardConfig {\n /** 글 컬렉션. 기본 `memiBoardPosts` */\n postsCollection: string\n /** 댓글 컬렉션. 기본 `memiBoardComments` */\n commentsCollection: string\n /** 좋아요 컬렉션. 기본 `memiBoardLikes` */\n likesCollection: string\n /** 보드 설정 컬렉션. 기본 `memiBoardSettings` */\n settingsCollection: string\n /** 역할 문서 컬렉션. 기본 `memiBoardUsers` */\n usersCollection: string\n auth: MemiBoardAuthOptions\n moderation: MemiBoardModerationOptions\n seo: MemiBoardSeoOptions\n}\n\nconst GLOBAL_KEY = '__MEMI_BOARD_CONFIG__' as const\n\nfunction getSharedConfig(): MemiBoardConfig {\n const g = globalThis as typeof globalThis & {\n [GLOBAL_KEY]?: MemiBoardConfig\n }\n if (!g[GLOBAL_KEY]) {\n g[GLOBAL_KEY] = reactive<MemiBoardConfig>({\n postsCollection: DEFAULT_POSTS_COLLECTION,\n commentsCollection: DEFAULT_COMMENTS_COLLECTION,\n likesCollection: DEFAULT_LIKES_COLLECTION,\n settingsCollection: DEFAULT_SETTINGS_COLLECTION,\n usersCollection: DEFAULT_USERS_COLLECTION,\n auth: { providers: ['google', 'apple'] },\n moderation: {\n enabled: true,\n model: 'gemini-3.5-flash-lite',\n onError: 'allow',\n moderateImages: false,\n localBlocklist: [],\n blockBanThreshold: 3,\n blockBanDecayMs: 24 * 60 * 60 * 1000,\n },\n seo: {\n enabled: true,\n siteName: 'Board',\n siteUrl: '',\n defaultOgImage: '',\n basePath: '/board',\n },\n })\n }\n return g[GLOBAL_KEY]\n}\n\nexport function configureMemiBoard(options: Partial<MemiBoardConfig>): void {\n const config = getSharedConfig()\n if (options.postsCollection != null && options.postsCollection !== '') {\n config.postsCollection = options.postsCollection.trim()\n }\n if (options.commentsCollection != null && options.commentsCollection !== '') {\n config.commentsCollection = options.commentsCollection.trim()\n }\n if (options.likesCollection != null && options.likesCollection !== '') {\n config.likesCollection = options.likesCollection.trim()\n }\n if (options.settingsCollection != null && options.settingsCollection !== '') {\n config.settingsCollection = options.settingsCollection.trim()\n }\n if (options.usersCollection != null && options.usersCollection !== '') {\n config.usersCollection = options.usersCollection.trim()\n }\n if (options.auth) Object.assign(config.auth, options.auth)\n if (options.moderation) Object.assign(config.moderation, options.moderation)\n if (options.seo) Object.assign(config.seo, options.seo)\n}\n\nexport function useMemiBoardConfig(): MemiBoardConfig {\n return getSharedConfig()\n}\n\nexport function useBoardPathConfig(): BoardPathConfig {\n return resolveBoardPathConfig(useMemiBoardConfig())\n}\n","/**\n * 제목 문자열을 URL-friendly slug로 변환\n * e.g. \"게시판 모듈 만들기!!\" → \"게시판-모듈-만들기\"\n */\nexport function slugify(text: string): string {\n return text\n .trim()\n .replace(/\\s+/g, '-') // 공백(연속 포함) → 하이픈\n .replace(/[^a-zA-Z0-9가-힣ㄱ-ㅎㅏ-ㅣ-]/g, '') // 허용 문자 외 제거\n .replace(/-+/g, '-') // 연속 하이픈 → 단일 하이픈\n .replace(/^-+|-+$/g, '') // 앞뒤 하이픈 제거\n}\n\n/** Storage 경로/파일명에 안전한 문자열로 변환 (한글은 유지) */\nexport function safeFileName(name: string, maxLength = 60): string {\n return name.replace(/[^a-zA-Z0-9가-힣ㄱ-ㅎ._-]/g, '-').slice(0, maxLength)\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"slugify-B5XHr4SZ.js","names":[],"sources":["../src/utils/boardPaths.ts","../src/config.ts","../src/utils/slugify.ts"],"sourcesContent":["/**\n * Firestore / Storage 경로\n *\n * ```\n * memiBoardUsers/{uid}\n * memiBoardSettings/{boardId} // 보드 설정 (구 memiBoards/{id} stub + settings/config 통합)\n * memiBoardPosts/{postId} // boardId 는 문서 필드\n * memiBoardPosts/{postId}/body/main\n * memiBoardComments/{commentId} // postId, boardId 는 문서 필드\n * memiBoardLikes/{postId}_{uid} // uid, postId, boardId 는 문서 필드\n * ```\n *\n * Storage: `memiBoardPosts/{postId}/...` (postId 가 전역 고유이므로 boardId 불필요)\n *\n * 카테고리 개념 없음 — 예전 category id 가 곧 boardId. boardId/postId 는 경로가\n * 아니라 문서 필드이므로, 보드/글 단위로 좁혀야 하는 쿼리는 항상 where 필터로\n * 스코핑한다 (컬렉션 자체는 항상 최상위 flat 컬렉션).\n */\nimport {\n collection,\n doc,\n type CollectionReference,\n type DocumentReference,\n type Firestore,\n} from 'firebase/firestore'\n\nexport const DEFAULT_POSTS_COLLECTION = 'memiBoardPosts'\nexport const DEFAULT_COMMENTS_COLLECTION = 'memiBoardComments'\nexport const DEFAULT_LIKES_COLLECTION = 'memiBoardLikes'\nexport const DEFAULT_SETTINGS_COLLECTION = 'memiBoardSettings'\nexport const DEFAULT_USERS_COLLECTION = 'memiBoardUsers'\n\nexport interface BoardPathConfig {\n postsCollection: string\n commentsCollection: string\n likesCollection: string\n settingsCollection: string\n usersCollection: string\n}\n\nexport function resolveBoardPathConfig(input: {\n postsCollection?: string\n commentsCollection?: string\n likesCollection?: string\n settingsCollection?: string\n usersCollection?: string\n}): BoardPathConfig {\n return {\n postsCollection: input.postsCollection?.trim() || DEFAULT_POSTS_COLLECTION,\n commentsCollection: input.commentsCollection?.trim() || DEFAULT_COMMENTS_COLLECTION,\n likesCollection: input.likesCollection?.trim() || DEFAULT_LIKES_COLLECTION,\n settingsCollection: input.settingsCollection?.trim() || DEFAULT_SETTINGS_COLLECTION,\n usersCollection: input.usersCollection?.trim() || DEFAULT_USERS_COLLECTION,\n }\n}\n\n/** 보드 설정 목록/문서 — 구 memiBoards/{boardId} stub + settings/config 통합 */\nexport function settingsCol(db: Firestore, cfg: BoardPathConfig): CollectionReference {\n return collection(db, cfg.settingsCollection)\n}\n\nexport function settingsDoc(\n db: Firestore,\n cfg: BoardPathConfig,\n boardId: string,\n): DocumentReference {\n return doc(db, cfg.settingsCollection, boardId)\n}\n\nexport function postsCol(db: Firestore, cfg: BoardPathConfig): CollectionReference {\n return collection(db, cfg.postsCollection)\n}\n\nexport function postDoc(db: Firestore, cfg: BoardPathConfig, postId: string): DocumentReference {\n return doc(db, cfg.postsCollection, postId)\n}\n\nexport function postBodyDoc(\n db: Firestore,\n cfg: BoardPathConfig,\n postId: string,\n): DocumentReference {\n return doc(db, cfg.postsCollection, postId, 'body', 'main')\n}\n\nexport function commentsCol(db: Firestore, cfg: BoardPathConfig): CollectionReference {\n return collection(db, cfg.commentsCollection)\n}\n\nexport function commentDoc(\n db: Firestore,\n cfg: BoardPathConfig,\n commentId: string,\n): DocumentReference {\n return doc(db, cfg.commentsCollection, commentId)\n}\n\nexport function likesCol(db: Firestore, cfg: BoardPathConfig): CollectionReference {\n return collection(db, cfg.likesCollection)\n}\n\n/** 좋아요 문서ID 관례: `${postId}_${uid}` — 유저당 글당 좋아요 1개, 토글은 create-or-delete */\nexport function likeDocId(postId: string, uid: string): string {\n return `${postId}_${uid}`\n}\n\nexport function likeDoc(\n db: Firestore,\n cfg: BoardPathConfig,\n postId: string,\n uid: string,\n): DocumentReference {\n return doc(db, cfg.likesCollection, likeDocId(postId, uid))\n}\n\nexport function boardUsersCol(db: Firestore, cfg: BoardPathConfig): CollectionReference {\n return collection(db, cfg.usersCollection)\n}\n\nexport function boardUserDoc(\n db: Firestore,\n cfg: BoardPathConfig,\n uid: string,\n): DocumentReference {\n return doc(db, cfg.usersCollection, uid)\n}\n\n/** 이메일만 담는 비공개 문서 — memiBoardUsers/{uid}는 공개 프로필이라 이메일만 분리한다. */\nexport function boardUserPrivateDoc(\n db: Firestore,\n cfg: BoardPathConfig,\n uid: string,\n): DocumentReference {\n return doc(db, cfg.usersCollection, uid, 'private', 'info')\n}\n\n/** 본인만 보는 작성자 메모 — 문서 ID 자체가 targetUid라 대상당 메모가 하나로 고정된다. */\nexport function authorMemoDoc(\n db: Firestore,\n cfg: BoardPathConfig,\n viewerUid: string,\n targetUid: string,\n): DocumentReference {\n return doc(db, cfg.usersCollection, viewerUid, 'memos', targetUid)\n}\n\n/** postId 가 전역 고유이므로 boardId 프리픽스 불필요 */\nexport function postStorageFolder(cfg: BoardPathConfig, postId: string): string {\n return `${cfg.postsCollection}/${postId}`\n}\n\nexport function boardSsrKey(cfg: BoardPathConfig, suffix: string): string {\n return `${cfg.postsCollection}/${cfg.usersCollection}/${suffix}`\n}\n","import { reactive } from 'vue'\nimport {\n DEFAULT_COMMENTS_COLLECTION,\n DEFAULT_LIKES_COLLECTION,\n DEFAULT_POSTS_COLLECTION,\n DEFAULT_SETTINGS_COLLECTION,\n DEFAULT_USERS_COLLECTION,\n resolveBoardPathConfig,\n type BoardPathConfig,\n} from './utils/boardPaths'\n\nexport interface MemiBoardAuthOptions {\n providers?: Array<'google' | 'apple' | 'emailPassword'>\n}\n\nexport interface MemiBoardModerationOptions {\n enabled?: boolean\n model?: string\n localBlocklist?: string[]\n onError?: 'allow' | 'block'\n moderateImages?: boolean\n useLimitedUseAppCheckTokens?: boolean\n blockBanThreshold?: number\n blockBanDecayMs?: number\n}\n\nexport interface MemiBoardSeoOptions {\n enabled?: boolean\n siteName?: string\n siteUrl?: string\n defaultOgImage?: string\n /**\n * 호스트 게시판 base path (trailing slash 없음). 기본 '/board'\n * canonical: {base}, {base}/{boardId}, {base}/{boardId}/{slug}\n */\n basePath?: string\n}\n\nexport interface MemiBoardConfig {\n /** 글 컬렉션. 기본 `memiBoardPosts` */\n postsCollection: string\n /** 댓글 컬렉션. 기본 `memiBoardComments` */\n commentsCollection: string\n /** 좋아요 컬렉션. 기본 `memiBoardLikes` */\n likesCollection: string\n /** 보드 설정 컬렉션. 기본 `memiBoardSettings` */\n settingsCollection: string\n /** 역할 문서 컬렉션. 기본 `memiBoardUsers` */\n usersCollection: string\n auth: MemiBoardAuthOptions\n moderation: MemiBoardModerationOptions\n seo: MemiBoardSeoOptions\n}\n\nconst GLOBAL_KEY = '__MEMI_BOARD_CONFIG__' as const\n\nfunction getSharedConfig(): MemiBoardConfig {\n const g = globalThis as typeof globalThis & {\n [GLOBAL_KEY]?: MemiBoardConfig\n }\n if (!g[GLOBAL_KEY]) {\n g[GLOBAL_KEY] = reactive<MemiBoardConfig>({\n postsCollection: DEFAULT_POSTS_COLLECTION,\n commentsCollection: DEFAULT_COMMENTS_COLLECTION,\n likesCollection: DEFAULT_LIKES_COLLECTION,\n settingsCollection: DEFAULT_SETTINGS_COLLECTION,\n usersCollection: DEFAULT_USERS_COLLECTION,\n auth: { providers: ['google', 'apple'] },\n moderation: {\n enabled: true,\n model: 'gemini-3.5-flash-lite',\n onError: 'allow',\n moderateImages: false,\n localBlocklist: [],\n blockBanThreshold: 10,\n blockBanDecayMs: 24 * 60 * 60 * 1000,\n },\n seo: {\n enabled: true,\n siteName: 'Board',\n siteUrl: '',\n defaultOgImage: '',\n basePath: '/board',\n },\n })\n }\n return g[GLOBAL_KEY]\n}\n\nexport function configureMemiBoard(options: Partial<MemiBoardConfig>): void {\n const config = getSharedConfig()\n if (options.postsCollection != null && options.postsCollection !== '') {\n config.postsCollection = options.postsCollection.trim()\n }\n if (options.commentsCollection != null && options.commentsCollection !== '') {\n config.commentsCollection = options.commentsCollection.trim()\n }\n if (options.likesCollection != null && options.likesCollection !== '') {\n config.likesCollection = options.likesCollection.trim()\n }\n if (options.settingsCollection != null && options.settingsCollection !== '') {\n config.settingsCollection = options.settingsCollection.trim()\n }\n if (options.usersCollection != null && options.usersCollection !== '') {\n config.usersCollection = options.usersCollection.trim()\n }\n if (options.auth) Object.assign(config.auth, options.auth)\n if (options.moderation) Object.assign(config.moderation, options.moderation)\n if (options.seo) Object.assign(config.seo, options.seo)\n}\n\nexport function useMemiBoardConfig(): MemiBoardConfig {\n return getSharedConfig()\n}\n\nexport function useBoardPathConfig(): BoardPathConfig {\n return resolveBoardPathConfig(useMemiBoardConfig())\n}\n","/**\n * 제목 문자열을 URL-friendly slug로 변환\n * e.g. \"게시판 모듈 만들기!!\" → \"게시판-모듈-만들기\"\n */\nexport function slugify(text: string): string {\n return text\n .trim()\n .replace(/\\s+/g, '-') // 공백(연속 포함) → 하이픈\n .replace(/[^a-zA-Z0-9가-힣ㄱ-ㅎㅏ-ㅣ-]/g, '') // 허용 문자 외 제거\n .replace(/-+/g, '-') // 연속 하이픈 → 단일 하이픈\n .replace(/^-+|-+$/g, '') // 앞뒤 하이픈 제거\n}\n\n/** Storage 경로/파일명에 안전한 문자열로 변환 (한글은 유지) */\nexport function safeFileName(name: string, maxLength = 60): string {\n return name.replace(/[^a-zA-Z0-9가-힣ㄱ-ㅎ._-]/g, '-').slice(0, maxLength)\n}\n"],"mappings":"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"}
package/dist/storage.js CHANGED
@@ -1,4 +1,4 @@
1
- import { C as e, i as t, k as n, t as r } from "./slugify-CDMIDDCN.js";
1
+ import { C as e, i as t, k as n, t as r } from "./slugify-B5XHr4SZ.js";
2
2
  import { useFirebaseApp as i } from "vuefire";
3
3
  import { deleteObject as a, getDownloadURL as o, getStorage as s, ref as c, uploadBytes as l, uploadBytesResumable as u } from "firebase/storage";
4
4
  //#region src/utils/compressImage.ts
@@ -28,7 +28,7 @@ function m(e) {
28
28
  return t === "image/heic" || t === "image/heif" || n.endsWith(".heic") || n.endsWith(".heif");
29
29
  }
30
30
  async function h(e, t = .85) {
31
- let { default: r } = await import("./heic2any-BHVEj2CD.js").then((e) => /* @__PURE__ */ n(e.default, 1)), i = await r({
31
+ let { default: r } = await import("./heic2any-BWRKffWN.js").then((e) => /* @__PURE__ */ n(e.default, 1)), i = await r({
32
32
  blob: e,
33
33
  toType: "image/jpeg",
34
34
  quality: t
package/dist/types.d.ts CHANGED
@@ -114,6 +114,10 @@ export interface BoardUserModel {
114
114
  displayName?: string | null;
115
115
  photoURL?: string | null;
116
116
  moderationBlockCount?: number;
117
+ /** AI 글쓰기 도우미 24시간 사용 횟수. */
118
+ aiWritingCount?: number;
119
+ /** AI 글쓰기 도우미 현재 24시간 구간 시작 시각. */
120
+ aiWritingWindowAt?: Timestamp;
117
121
  updatedAt?: Timestamp;
118
122
  /** 최초 가입일. 기존 사용자는 비어 있고, 백필 스크립트나 다음 로그인 시 채운다. */
119
123
  joinedAt?: Timestamp;
@@ -0,0 +1,5 @@
1
+ import { Firestore } from 'firebase/firestore';
2
+ import { FirebaseStorage } from 'firebase/storage';
3
+ import { BoardPathConfig } from './boardPaths';
4
+ /** 글 하나의 댓글(답글 포함), 본문, 첨부 Storage, 메타를 순서대로 삭제한다. */
5
+ export declare function deletePostCascade(db: Firestore, storage: FirebaseStorage, cfg: BoardPathConfig, id: string): Promise<void>;
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * 게시판 검열 차단 누적 → 일시 이용 제한 (호스트 contentWarning 과 동일 모델).
3
3
  * - 콘텐츠 차단(로컬/AI flagged) 시 +1
4
- * - 유효 경고 ≥ threshold(기본 3) → 글/댓글 작성 불가
4
+ * - 유효 경고 ≥ threshold(기본 10) → 글/댓글 작성 불가
5
5
  * - 24h마다 lazy 차감 (moderationBlockAt 기준)
6
6
  */
7
- export declare const DEFAULT_BLOCK_BAN_THRESHOLD = 3;
7
+ export declare const DEFAULT_BLOCK_BAN_THRESHOLD = 10;
8
8
  export declare const DEFAULT_BLOCK_BAN_DECAY_MS: number;
9
9
  export declare function effectiveModerationBlockCount(count: number | null | undefined, blockAtMs: number | null | undefined, nowMs?: number, decayMs?: number): number;
10
10
  /** 제한 해제 시각(ms). 제한 없으면 null. */
@@ -138,13 +138,33 @@ service cloud.firestore {
138
138
  && request.resource.data.role == resource.data.role
139
139
  && (
140
140
  !request.resource.data.diff(resource.data).affectedKeys()
141
- .hasAny(['moderationBlockCount', 'moderationBlockAt'])
141
+ .hasAny(['moderationBlockCount', 'moderationBlockAt', 'aiWritingCount', 'aiWritingWindowAt'])
142
142
  || (
143
143
  request.resource.data.diff(resource.data).affectedKeys()
144
144
  .hasOnly(['moderationBlockCount', 'moderationBlockAt', 'updatedAt'])
145
145
  && request.resource.data.moderationBlockCount
146
146
  == resource.data.get('moderationBlockCount', 0) + 1
147
147
  )
148
+ || (
149
+ request.resource.data.diff(resource.data).affectedKeys()
150
+ .hasOnly(['aiWritingCount', 'aiWritingWindowAt', 'updatedAt'])
151
+ && request.resource.data.aiWritingCount <= 10
152
+ && (
153
+ (
154
+ 'aiWritingWindowAt' in resource.data
155
+ && request.time < resource.data.aiWritingWindowAt + duration.value(24, 'h')
156
+ && request.resource.data.aiWritingWindowAt == resource.data.aiWritingWindowAt
157
+ && request.resource.data.aiWritingCount
158
+ == resource.data.get('aiWritingCount', 0) + 1
159
+ )
160
+ || (
161
+ (!('aiWritingWindowAt' in resource.data)
162
+ || request.time >= resource.data.aiWritingWindowAt + duration.value(24, 'h'))
163
+ && request.resource.data.aiWritingWindowAt == request.time
164
+ && request.resource.data.aiWritingCount == 1
165
+ )
166
+ )
167
+ )
148
168
  ));
149
169
 
150
170
  // 다른 작성자에 대해 나만 보는 개인 메모. 문서 ID가 targetUid라 대상당 하나로
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memi-board",
3
- "version": "0.30.2",
3
+ "version": "0.32.0",
4
4
  "type": "module",
5
5
  "description": "Nuxt 4 + Nuxt UI + VueFire/Firebase 전용 게시판 모듈",
6
6
  "license": "MIT",