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.
- package/README.md +10 -3
- package/dist/composables/useMemiBoardWritingAssistant.d.ts +13 -0
- package/dist/{heic2any-BHVEj2CD.js → heic2any-BWRKffWN.js} +2 -2
- package/dist/{heic2any-BHVEj2CD.js.map → heic2any-BWRKffWN.js.map} +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +83 -12
- package/dist/index.js.map +1 -1
- package/dist/runtime/components/Editor.vue +94 -5
- package/dist/{slugify-CDMIDDCN.js → slugify-B5XHr4SZ.js} +2 -2
- package/dist/{slugify-CDMIDDCN.js.map → slugify-B5XHr4SZ.js.map} +1 -1
- package/dist/storage.js +2 -2
- package/dist/types.d.ts +4 -0
- package/dist/utils/moderation-strike.d.ts +2 -2
- package/docs/firestore.rules.example +21 -1
- package/package.json +1 -1
|
@@ -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
|
-
<
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
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:
|
|
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-
|
|
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":";;;;;;;;;;;;aA0Ba,IAA2B,kBAC3B,IAA8B,qBAC9B,IAA2B,kBAC3B,IAA8B,qBAC9B,IAA2B;AAUxC,SAAgB,EAAuB,GAMnB;CAClB,OAAO;EACL,iBAAiB,EAAM,iBAAiB,KAAK,KAAA;EAC7C,oBAAoB,EAAM,oBAAoB,KAAK,KAAA;EACnD,iBAAiB,EAAM,iBAAiB,KAAK,KAAA;EAC7C,oBAAoB,EAAM,oBAAoB,KAAK,KAAA;EACnD,iBAAiB,EAAM,iBAAiB,KAAK,KAAA;CAC/C;AACF;AAGA,SAAgB,EAAY,GAAe,GAA2C;CACpF,OAAO,EAAW,GAAI,EAAI,kBAAkB;AAC9C;AAEA,SAAgB,EACd,GACA,GACA,GACmB;CACnB,OAAO,EAAI,GAAI,EAAI,oBAAoB,CAAO;AAChD;AAEA,SAAgB,EAAS,GAAe,GAA2C;CACjF,OAAO,EAAW,GAAI,EAAI,eAAe;AAC3C;AAEA,SAAgB,EAAQ,GAAe,GAAsB,GAAmC;CAC9F,OAAO,EAAI,GAAI,EAAI,iBAAiB,CAAM;AAC5C;AAEA,SAAgB,EACd,GACA,GACA,GACmB;CACnB,OAAO,EAAI,GAAI,EAAI,iBAAiB,GAAQ,QAAQ,MAAM;AAC5D;AAEA,SAAgB,EAAY,GAAe,GAA2C;CACpF,OAAO,EAAW,GAAI,EAAI,kBAAkB;AAC9C;AAEA,SAAgB,EACd,GACA,GACA,GACmB;CACnB,OAAO,EAAI,GAAI,EAAI,oBAAoB,CAAS;AAClD;AAEA,SAAgB,EAAS,GAAe,GAA2C;CACjF,OAAO,EAAW,GAAI,EAAI,eAAe;AAC3C;AAGA,SAAgB,EAAU,GAAgB,GAAqB;CAC7D,OAAO,GAAG,EAAO,GAAG;AACtB;AAEA,SAAgB,EACd,GACA,GACA,GACA,GACmB;CACnB,OAAO,EAAI,GAAI,EAAI,iBAAiB,EAAU,GAAQ,CAAG,CAAC;AAC5D;AAEA,SAAgB,EAAc,GAAe,GAA2C;CACtF,OAAO,EAAW,GAAI,EAAI,eAAe;AAC3C;AAEA,SAAgB,EACd,GACA,GACA,GACmB;CACnB,OAAO,EAAI,GAAI,EAAI,iBAAiB,CAAG;AACzC;AAGA,SAAgB,EACd,GACA,GACA,GACmB;CACnB,OAAO,EAAI,GAAI,EAAI,iBAAiB,GAAK,WAAW,MAAM;AAC5D;AAGA,SAAgB,EACd,GACA,GACA,GACA,GACmB;CACnB,OAAO,EAAI,GAAI,EAAI,iBAAiB,GAAW,SAAS,CAAS;AACnE;AAGA,SAAgB,EAAkB,GAAsB,GAAwB;CAC9E,OAAO,GAAG,EAAI,gBAAgB,GAAG;AACnC;AAEA,SAAgB,EAAY,GAAsB,GAAwB;CACxE,OAAO,GAAG,EAAI,gBAAgB,GAAG,EAAI,gBAAgB,GAAG;AAC1D;;;ACnGA,IAAM,IAAa;AAEnB,SAAS,IAAmC;CAC1C,IAAM,IAAI;CA6BV,OA1BK,EAAE,OACL,EAAE,KAAc,EAA0B;EACxC,iBAAiB;EACjB,oBAAoB;EACpB,iBAAiB;EACjB,oBAAoB;EACpB,iBAAiB;EACjB,MAAM,EAAE,WAAW,CAAC,UAAU,OAAO,EAAE;EACvC,YAAY;GACV,SAAS;GACT,OAAO;GACP,SAAS;GACT,gBAAgB;GAChB,gBAAgB,CAAC;GACjB,mBAAmB;GACnB,iBAAiB;EACnB;EACA,KAAK;GACH,SAAS;GACT,UAAU;GACV,SAAS;GACT,gBAAgB;GAChB,UAAU;EACZ;CACF,CAAC,IAEI,EAAE;AACX;AAEA,SAAgB,EAAmB,GAAyC;CAC1E,IAAM,IAAS,EAAgB;CAkB/B,AAjBI,EAAQ,mBAAmB,QAAQ,EAAQ,oBAAoB,OACjE,EAAO,kBAAkB,EAAQ,gBAAgB,KAAK,IAEpD,EAAQ,sBAAsB,QAAQ,EAAQ,uBAAuB,OACvE,EAAO,qBAAqB,EAAQ,mBAAmB,KAAK,IAE1D,EAAQ,mBAAmB,QAAQ,EAAQ,oBAAoB,OACjE,EAAO,kBAAkB,EAAQ,gBAAgB,KAAK,IAEpD,EAAQ,sBAAsB,QAAQ,EAAQ,uBAAuB,OACvE,EAAO,qBAAqB,EAAQ,mBAAmB,KAAK,IAE1D,EAAQ,mBAAmB,QAAQ,EAAQ,oBAAoB,OACjE,EAAO,kBAAkB,EAAQ,gBAAgB,KAAK,IAEpD,EAAQ,QAAM,OAAO,OAAO,EAAO,MAAM,EAAQ,IAAI,GACrD,EAAQ,cAAY,OAAO,OAAO,EAAO,YAAY,EAAQ,UAAU,GACvE,EAAQ,OAAK,OAAO,OAAO,EAAO,KAAK,EAAQ,GAAG;AACxD;AAEA,SAAgB,IAAsC;CACpD,OAAO,EAAgB;AACzB;AAEA,SAAgB,IAAsC;CACpD,OAAO,EAAuB,EAAmB,CAAC;AACpD;;;ACjHA,SAAgB,EAAQ,GAAsB;CAC5C,OAAO,EACJ,KAAK,CAAC,CACN,QAAQ,QAAQ,GAAG,CAAC,CACpB,QAAQ,2BAA2B,EAAE,CAAC,CACtC,QAAQ,OAAO,GAAG,CAAC,CACnB,QAAQ,YAAY,EAAE;AAC3B;AAGA,SAAgB,EAAa,GAAc,IAAY,IAAY;CACjE,OAAO,EAAK,QAAQ,0BAA0B,GAAG,CAAC,CAAC,MAAM,GAAG,CAAS;AACvE"}
|
|
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-
|
|
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-
|
|
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(기본
|
|
4
|
+
* - 유효 경고 ≥ threshold(기본 10) → 글/댓글 작성 불가
|
|
5
5
|
* - 24h마다 lazy 차감 (moderationBlockAt 기준)
|
|
6
6
|
*/
|
|
7
|
-
export declare const DEFAULT_BLOCK_BAN_THRESHOLD =
|
|
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라 대상당 하나로
|