memi-board 0.30.2 → 0.31.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,8 @@ const submitHint = ref('')
54
57
  const error = ref('')
55
58
  const imageUploading = ref(false)
56
59
  const imageUploadError = ref('')
60
+ const aiRunning = ref(false)
61
+ const aiError = ref('')
57
62
 
58
63
  const attachmentNamespace = ref(props.postId ?? createPostId())
59
64
  const uploadedEditorImages = ref<EditorImageEntry[]>([])
@@ -439,11 +444,72 @@ const toolbarItems = computed(() => {
439
444
  ]
440
445
  })
441
446
 
447
+ const aiMenuItems = computed(() => [
448
+ [
449
+ { label: '맞춤법 검사', icon: 'i-lucide-spell-check-2', onSelect: () => runWritingAssistant('proofread') },
450
+ { label: '문장 다듬기', icon: 'i-lucide-wand-sparkles', onSelect: () => runWritingAssistant('polish') },
451
+ { label: '자연스럽게 이어쓰기', icon: 'i-lucide-text-cursor-input', onSelect: () => runWritingAssistant('continue') },
452
+ { label: '짧게 요약하기', icon: 'i-lucide-scan-text', onSelect: () => runWritingAssistant('summarize') },
453
+ ],
454
+ [
455
+ { label: '영어로 번역', icon: 'i-lucide-languages', onSelect: () => runWritingAssistant('translate-en') },
456
+ { label: '한국어로 번역', icon: 'i-lucide-languages', onSelect: () => runWritingAssistant('translate-ko') },
457
+ ],
458
+ [
459
+ { label: '본문에서 제목 만들기', icon: 'i-lucide-heading-1', onSelect: () => runWritingAssistant('title') },
460
+ ],
461
+ ])
462
+
442
463
  function resolveEditor(): any | null {
443
464
  const ed = (editorRef.value as any)?.editor
444
465
  return ed?.value ?? ed ?? null
445
466
  }
446
467
 
468
+ async function runWritingAssistant(action: WritingAssistantAction) {
469
+ const editor = resolveEditor()
470
+ if (!editor || aiRunning.value) return
471
+ aiError.value = ''
472
+
473
+ const { from, to } = editor.state.selection
474
+ const hasSelection = from !== to
475
+ const selectedText = hasSelection ? editor.state.doc.textBetween(from, to, '\n') : ''
476
+ const source = action === 'title'
477
+ ? plainTextFromHtml(content.value)
478
+ : hasSelection ? selectedText : content.value
479
+
480
+ aiRunning.value = true
481
+ try {
482
+ const result = await assist({
483
+ action,
484
+ content: source,
485
+ title: title.value,
486
+ selection: hasSelection || action === 'title',
487
+ })
488
+
489
+ if (action === 'title') {
490
+ title.value = result.replace(/^['"“”]|['"“”]$/g, '').trim()
491
+ }
492
+ else if (action === 'continue') {
493
+ const position = hasSelection ? to : editor.state.doc.content.size
494
+ editor.chain().focus().insertContentAt(position, result).run()
495
+ }
496
+ else if (hasSelection) {
497
+ editor.chain().focus().insertContentAt({ from, to }, result).run()
498
+ }
499
+ else {
500
+ editor.commands.setContent(result, { emitUpdate: true })
501
+ editor.chain().focus('end').run()
502
+ }
503
+ }
504
+ catch (cause) {
505
+ console.warn('[memi-board] writing assistant failed', cause)
506
+ aiError.value = cause instanceof Error ? cause.message : 'AI 작업에 실패했습니다. 다시 시도해 주세요.'
507
+ }
508
+ finally {
509
+ aiRunning.value = false
510
+ }
511
+ }
512
+
447
513
  // 제목에서 Tab 누르면 툴바 버튼들 건너뛰고 본문으로 바로 이동
448
514
  function focusContentFromTitle() {
449
515
  resolveEditor()?.chain().focus().run()
@@ -892,15 +958,38 @@ async function handleSubmit() {
892
958
  class="board-content w-full"
893
959
  >
894
960
  <template #default="{ editor }">
895
- <UEditorToolbar
896
- :editor="editor"
897
- :items="toolbarItems"
898
- class="border-b border-default p-2 flex-wrap"
899
- />
961
+ <div class="flex items-start border-b border-default">
962
+ <UEditorToolbar
963
+ :editor="editor"
964
+ :items="toolbarItems"
965
+ class="min-w-0 flex-1 p-2 flex-wrap"
966
+ />
967
+ <UDropdownMenu :items="aiMenuItems">
968
+ <UButton
969
+ icon="i-lucide-sparkles"
970
+ label="AI"
971
+ color="neutral"
972
+ variant="ghost"
973
+ size="sm"
974
+ class="m-2 shrink-0"
975
+ :loading="aiRunning"
976
+ :disabled="aiRunning"
977
+ aria-label="AI 글쓰기 도우미"
978
+ />
979
+ </UDropdownMenu>
980
+ </div>
900
981
  <UEditorDragHandle :editor="editor" />
901
982
  </template>
902
983
  </UEditor>
903
984
 
985
+ <p
986
+ v-if="aiError"
987
+ class="flex items-center gap-2 border-t border-default px-4 py-2 text-xs text-error"
988
+ >
989
+ <UIcon name="i-lucide-circle-alert" class="size-3.5 shrink-0" />
990
+ {{ aiError }}
991
+ </p>
992
+
904
993
  <p
905
994
  v-if="imageUploading && !isImageListView"
906
995
  class="flex items-center gap-2 border-t border-default px-4 py-2 text-xs text-muted"
@@ -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;
@@ -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.31.0",
4
4
  "type": "module",
5
5
  "description": "Nuxt 4 + Nuxt UI + VueFire/Firebase 전용 게시판 모듈",
6
6
  "license": "MIT",