memi-board 0.4.4 → 0.4.5

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/dist/config.d.ts CHANGED
@@ -7,6 +7,8 @@ export interface MemiBoardModerationOptions {
7
7
  localBlocklist?: string[];
8
8
  onError?: 'allow' | 'block';
9
9
  moderateImages?: boolean;
10
+ /** AI Logic App Check limited-use 토큰 (재생 보호). 기본 true */
11
+ useLimitedUseAppCheckTokens?: boolean;
10
12
  }
11
13
  export interface MemiBoardConfig {
12
14
  collectionPrefix: string;
package/dist/index.js CHANGED
@@ -13,7 +13,7 @@ function B() {
13
13
  auth: { providers: ["google", "apple"] },
14
14
  moderation: {
15
15
  enabled: !0,
16
- model: "gemini-2.5-flash",
16
+ model: "gemini-3.5-flash-lite",
17
17
  onError: "allow",
18
18
  moderateImages: !1,
19
19
  localBlocklist: []
@@ -216,6 +216,7 @@ function q(t) {
216
216
  var J = "당신은 게시판 콘텐츠를 심사하는 엄격한 모더레이터입니다.\n아래 텍스트에 욕설·비속어·성희롱·혐오·위협·스팸·도배성 광고가 있으면 반드시 거부(flagged=true)합니다.\n\nflagged=true 예:\n- 욕설/비속어: 씨발, 시발, 병신, 지랄, 좆, 개새끼, 존나, fuck, shit, bitch 등\n- 성적 모욕, 혐오 발언, 협박\n- 게시판 주제와 무관한 도배성 광고/스팸\n\nflagged=false:\n- 게시판 주제에 맞는 정상적인 글/댓글 (강한 의견이나 정당한 비판은 허용)\n\nJSON만 출력:\n{\"flagged\":boolean,\"category\":\"none\"|\"abuse\"|\"spam\"|\"adult\"|\"violence\"|\"other\",\"reason\":string}\n- flagged=true면 category는 상황에 맞게 고르고, reason은 한국어 1문장 (예: \"욕설이 포함되어 있어 게시할 수 없습니다.\")\n- flagged=false면 category는 \"none\", reason은 \"\"", ae = [
217
217
  "씨발",
218
218
  "시발",
219
+ "시이발",
219
220
  "씨팔",
220
221
  "시팔",
221
222
  "병신",
@@ -225,6 +226,7 @@ var J = "당신은 게시판 콘텐츠를 심사하는 엄격한 모더레이터
225
226
  "존나",
226
227
  "개새끼",
227
228
  "개새",
229
+ "새끼",
228
230
  "꺼져",
229
231
  "닥쳐",
230
232
  "씹",
@@ -295,13 +297,14 @@ function Q() {
295
297
  } : null;
296
298
  }
297
299
  function i() {
298
- let n = e.model ?? "gemini-2.5-flash", r = R(t, {
300
+ let n = e.model ?? "gemini-3.5-flash-lite", r = e.useLimitedUseAppCheckTokens !== !1, i = R(t, {
299
301
  backend: new I(),
300
- useLimitedUseAppCheckTokens: !0
302
+ useLimitedUseAppCheckTokens: r
301
303
  });
302
304
  return {
303
305
  modelName: n,
304
- model: re(r, {
306
+ useLimited: r,
307
+ model: re(i, {
305
308
  model: n,
306
309
  systemInstruction: J,
307
310
  generationConfig: {
@@ -321,7 +324,7 @@ function Q() {
321
324
  return e.onError === "block" ? {
322
325
  flagged: !0,
323
326
  category: "other",
324
- reason: "검열 서비스에 일시적으로 연결할 수 없어 제출이 보류되었습니다. 잠시 후 다시 시도해 주세요.",
327
+ reason: "AI 검열에 실패해 글을 등록할 수 없습니다. (App Check/네트워크 확인) 잠시 후 다시 시도해 주세요.",
325
328
  error: !0,
326
329
  via: "ai-error-block"
327
330
  } : {
@@ -338,7 +341,7 @@ function Q() {
338
341
  preview: o.slice(0, 80),
339
342
  length: o.length,
340
343
  enabled: e.enabled !== !1,
341
- model: e.model ?? "gemini-2.5-flash",
344
+ model: e.model ?? "gemini-3.5-flash-lite",
342
345
  onError: e.onError ?? "allow"
343
346
  }), !o) {
344
347
  let e = {
@@ -363,40 +366,42 @@ function Q() {
363
366
  return Z("result", e), e;
364
367
  }
365
368
  try {
366
- let { model: e, modelName: t } = i();
369
+ let { model: e, modelName: t, useLimited: n } = i();
367
370
  Z("ai-call", {
368
371
  modelName: t,
369
- useLimitedUseAppCheckTokens: !0
372
+ useLimitedUseAppCheckTokens: n
370
373
  });
371
- let n = performance.now(), r = await e.generateContent(`심사할 텍스트:\n"""${o.slice(0, 4e3)}"""`), s = Math.round(performance.now() - n), c = r.response.text();
374
+ let r = performance.now(), s = await e.generateContent(`심사할 텍스트:\n"""${o.slice(0, 4e3)}"""`), c = Math.round(performance.now() - r), l = s.response.text();
372
375
  Z("ai-raw", {
373
- ms: s,
374
- raw: c.slice(0, 500)
376
+ ms: c,
377
+ raw: l.slice(0, 500)
375
378
  });
376
- let l = Y(c);
377
- if (!l) {
378
- Z("ai-parse-fail", { raw: c.slice(0, 300) });
379
+ let u = Y(l);
380
+ if (!u) {
381
+ Z("ai-parse-fail", { raw: l.slice(0, 300) });
379
382
  let e = a();
380
383
  return Z("result", e), e;
381
384
  }
382
- let u = {
383
- flagged: l.flagged,
384
- category: l.category,
385
- reason: l.reason,
385
+ let d = {
386
+ flagged: u.flagged,
387
+ category: u.category,
388
+ reason: u.reason,
386
389
  via: "ai"
387
390
  };
388
391
  return Z("result", {
389
- ...u,
390
- ms: s
391
- }), u;
392
+ ...d,
393
+ ms: c
394
+ }), d;
392
395
  } catch (e) {
396
+ let t = e;
393
397
  Z("ai-error", {
394
398
  message: e instanceof Error ? e.message : String(e),
395
399
  name: e instanceof Error ? e.name : void 0,
396
- code: e?.code
400
+ code: t?.code,
401
+ cause: t?.cause instanceof Error ? t.cause.message : t?.cause
397
402
  });
398
- let t = a();
399
- return Z("result", t), t;
403
+ let n = a();
404
+ return Z("result", n), n;
400
405
  }
401
406
  }
402
407
  async function c(t) {
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../src/config.ts","../src/composables/useMemiBoardAuth.ts","../src/utils/slugify.ts","../src/composables/useMemiBoardPosts.ts","../src/composables/useMemiBoardComments.ts","../src/utils/moderation-prompt.ts","../src/composables/useMemiBoardModeration.ts","../src/composables/useMemiBoardStorage.ts","../src/composables/useMemiBoardSettings.ts","../src/data/versionHistory.ts","../src/utils/formatDate.ts"],"sourcesContent":["import { reactive } from 'vue'\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}\n\nexport interface MemiBoardConfig {\n collectionPrefix: string\n auth: MemiBoardAuthOptions\n moderation: MemiBoardModerationOptions\n}\n\nconst GLOBAL_KEY = '__MEMI_BOARD_CONFIG__' as const\n\n/**\n * link:/file: 모노레포에서 Vite 가 dist 와 src 를 서로 다른 모듈 id 로 묶으면\n * 모듈 스코프 singleton 이 둘로 갈라져 configure 가 안 먹는 문제가 난다.\n * globalThis 에 한 번만 올려 플러그인·컴포넌트가 항상 같은 config 를 보게 한다.\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 collectionPrefix: 'memiBoard',\n auth: { providers: ['google', 'apple'] },\n moderation: {\n enabled: true,\n model: 'gemini-2.5-flash',\n onError: 'allow',\n moderateImages: false,\n localBlocklist: [],\n },\n })\n }\n return g[GLOBAL_KEY]\n}\n\n/**\n * 앱 부팅 시(플러그인 등에서) 한 번 호출한다. 모든 요청/세션이 같은 값을 보므로\n * 서버 렌더링에서도 안전하다 — 사용자별/요청별로 달라지는 값이 아니다.\n */\nexport function configureMemiBoard(options: Partial<MemiBoardConfig>): void {\n const config = getSharedConfig()\n if (options.collectionPrefix) config.collectionPrefix = options.collectionPrefix\n if (options.auth) Object.assign(config.auth, options.auth)\n if (options.moderation) Object.assign(config.moderation, options.moderation)\n}\n\nexport function useMemiBoardConfig(): MemiBoardConfig {\n return getSharedConfig()\n}\n","import { ref, computed, watch } from 'vue'\nimport type { Ref, ComputedRef } from 'vue'\nimport { useFirestore, useFirebaseAuth, useCurrentUser } from 'vuefire'\nimport { doc, getDoc, setDoc, updateDoc, serverTimestamp } from 'firebase/firestore'\nimport {\n GoogleAuthProvider,\n OAuthProvider,\n signInWithPopup,\n signInWithEmailAndPassword,\n createUserWithEmailAndPassword,\n signOut as firebaseSignOut,\n updateProfile,\n} from 'firebase/auth'\nimport type { User } from 'firebase/auth'\nimport { useMemiBoardConfig } from '../config'\nimport type { PostModel, CommentModel } from '../types'\n\nexport interface UseMemiBoardAuthReturn {\n user: ReturnType<typeof useCurrentUser>\n isSignedIn: ComputedRef<boolean>\n isAdmin: ComputedRef<boolean>\n rolePending: Ref<boolean>\n signInWithGoogle: () => ReturnType<typeof signInWithPopup>\n signInWithApple: () => ReturnType<typeof signInWithPopup>\n signInWithEmail: (email: string, password: string) => ReturnType<typeof signInWithEmailAndPassword>\n signUpWithEmail: (email: string, password: string, displayName?: string) => ReturnType<typeof createUserWithEmailAndPassword>\n signOut: () => Promise<void>\n canEdit: (post: Pick<PostModel, 'authorUid'>) => boolean\n canDelete: (post: Pick<PostModel, 'authorUid'>) => boolean\n canDeleteComment: (comment: Pick<CommentModel, 'authorUid'>) => boolean\n}\n\n/**\n * 인증 상태 + 게시판 역할(role) 관리.\n * role은 {prefix}Users/{uid} 문서에서만 읽고, 모듈은 절대 'admin'을 쓰지 않는다\n * (관리자 승격은 호스트 프로젝트 소유자가 Firebase 콘솔에서 직접 설정).\n */\nexport function useMemiBoardAuth(): UseMemiBoardAuthReturn {\n const config = useMemiBoardConfig()\n const db = useFirestore()\n const auth = useFirebaseAuth()\n const user = useCurrentUser()\n\n const role = ref<'user' | 'admin'>('user')\n const rolePending = ref(true)\n\n function roleDocRef(uid: string) {\n return doc(db, `${config.collectionPrefix}Users`, uid)\n }\n\n /** 최초 로그인 시 역할 문서를 생성(role: 'user' 고정)하고, 이후에는 표시용 정보만 갱신한다. */\n async function ensureUserDoc(firebaseUser: User): Promise<'user' | 'admin'> {\n const ref = roleDocRef(firebaseUser.uid)\n const snap = await getDoc(ref)\n if (!snap.exists()) {\n await setDoc(ref, {\n role: 'user',\n displayName: firebaseUser.displayName,\n updatedAt: serverTimestamp(),\n })\n return 'user'\n }\n await updateDoc(ref, {\n displayName: firebaseUser.displayName,\n updatedAt: serverTimestamp(),\n })\n return (snap.data().role as 'user' | 'admin') ?? 'user'\n }\n\n watch(\n user,\n async (current) => {\n rolePending.value = true\n if (!current) {\n role.value = 'user'\n rolePending.value = false\n return\n }\n try {\n role.value = await ensureUserDoc(current)\n }\n catch {\n role.value = 'user'\n }\n finally {\n rolePending.value = false\n }\n },\n { immediate: true },\n )\n\n const isSignedIn = computed(() => !!user.value)\n const isAdmin = computed(() => role.value === 'admin')\n\n function requireAuth() {\n if (!auth) throw new Error('[memi-board] Firebase Auth가 초기화되지 않았습니다.')\n return auth\n }\n\n async function signInWithGoogle() {\n return signInWithPopup(requireAuth(), new GoogleAuthProvider())\n }\n\n async function signInWithApple() {\n const provider = new OAuthProvider('apple.com')\n provider.addScope('email')\n provider.addScope('name')\n return signInWithPopup(requireAuth(), provider)\n }\n\n async function signInWithEmail(email: string, password: string) {\n return signInWithEmailAndPassword(requireAuth(), email, password)\n }\n\n async function signUpWithEmail(email: string, password: string, displayName?: string) {\n const credential = await createUserWithEmailAndPassword(requireAuth(), email, password)\n if (displayName) await updateProfile(credential.user, { displayName })\n return credential\n }\n\n async function signOut() {\n await firebaseSignOut(requireAuth())\n }\n\n function canEdit(post: Pick<PostModel, 'authorUid'>) {\n return isSignedIn.value && (user.value?.uid === post.authorUid || isAdmin.value)\n }\n\n function canDeleteComment(comment: Pick<CommentModel, 'authorUid'>) {\n return isSignedIn.value && (user.value?.uid === comment.authorUid || isAdmin.value)\n }\n\n return {\n user,\n isSignedIn,\n isAdmin,\n rolePending,\n signInWithGoogle,\n signInWithApple,\n signInWithEmail,\n signUpWithEmail,\n signOut,\n canEdit,\n canDelete: canEdit,\n canDeleteComment,\n }\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","import {\n collection,\n doc,\n getDoc,\n getDocs,\n setDoc,\n updateDoc,\n deleteDoc,\n query,\n where,\n orderBy,\n limit as fbLimit,\n startAfter,\n serverTimestamp,\n writeBatch,\n deleteField,\n} from 'firebase/firestore'\nimport type { QueryConstraint } from 'firebase/firestore'\nimport type { DocumentData, QueryDocumentSnapshot } from 'firebase/firestore'\nimport { getStorage, ref as storageRef, listAll, deleteObject } from 'firebase/storage'\nimport type { StorageReference } from 'firebase/storage'\nimport { useFirebaseApp, useFirestore } from 'vuefire'\nimport { slugify } from '../utils/slugify'\nimport { useMemiBoardConfig } from '../config'\nimport type { Attachment, PostDetail, PostModel } from '../types'\n\nasync function deleteStorageFolder(folderRef: StorageReference): Promise<void> {\n const list = await listAll(folderRef).catch(() => ({ items: [], prefixes: [] }))\n await Promise.all([\n ...list.items.map(item => deleteObject(item)),\n ...list.prefixes.map(prefix => deleteStorageFolder(prefix)),\n ])\n}\n\nexport interface CreatePostInput {\n title: string\n content: string\n tags?: string[]\n category?: string\n attachments?: Attachment[]\n authorUid: string\n authorName: string | null\n authorPhoto: string | null\n moderationModel?: string\n}\n\nexport interface UpdatePostInput {\n title: string\n content: string\n tags?: string[]\n category?: string\n attachments?: Attachment[]\n}\n\n/** 목록 조회는 posts/{id} 메타만 읽고, 본문(body/main)은 상세 조회 시에만 읽는다. */\nexport function useMemiBoardPosts() {\n const config = useMemiBoardConfig()\n const db = useFirestore()\n const app = useFirebaseApp()\n // prefix 는 setup 시점에 고정하지 않는다 (configure 이전 호출·이중 인스턴스 대비)\n const prefix = () => config.collectionPrefix\n\n const postsCol = () => collection(db, `${prefix()}Posts`)\n const postDoc = (id: string) => doc(db, `${prefix()}Posts`, id)\n const bodyDoc = (id: string) => doc(db, `${prefix()}Posts`, id, 'body', 'main')\n const commentsCol = (id: string) => collection(db, `${prefix()}Posts`, id, 'comments')\n\n async function getPosts(opts: {\n pageSize?: number\n cursor?: QueryDocumentSnapshot<DocumentData>\n /** 지정 시 해당 카테고리 글만 (boardSettings 의 category id) */\n category?: string\n } = {}) {\n const pageSize = opts.pageSize ?? 20\n const constraints: QueryConstraint[] = []\n if (opts.category) {\n // 복합 인덱스: category ASC + createdAt DESC (호스트 firestore.indexes.json)\n constraints.push(where('category', '==', opts.category))\n }\n constraints.push(orderBy('createdAt', 'desc'))\n if (opts.cursor) constraints.push(startAfter(opts.cursor))\n constraints.push(fbLimit(pageSize + 1))\n\n const snapshot = await getDocs(query(postsCol(), ...constraints))\n const docs = snapshot.docs.slice(0, pageSize)\n return {\n posts: docs.map(d => ({ id: d.id, ...d.data() }) as PostModel),\n cursor: docs[docs.length - 1] as QueryDocumentSnapshot<DocumentData> | undefined,\n hasMore: snapshot.docs.length > pageSize,\n }\n }\n\n async function getPost(id: string): Promise<PostDetail | null> {\n const [metaSnap, bodySnap] = await Promise.all([getDoc(postDoc(id)), getDoc(bodyDoc(id))])\n if (!metaSnap.exists()) return null\n return {\n id: metaSnap.id,\n ...metaSnap.data(),\n content: bodySnap.exists() ? ((bodySnap.data() as { content: string }).content ?? '') : '',\n } as PostDetail\n }\n\n async function createPost(input: CreatePostInput): Promise<string> {\n const baseSlug = slugify(input.title) || 'post'\n let slug = baseSlug\n let counter = 1\n while ((await getDoc(postDoc(slug))).exists()) {\n counter++\n slug = `${baseSlug}-${counter}`\n }\n\n await Promise.all([\n setDoc(postDoc(slug), {\n title: input.title,\n tags: input.tags ?? [],\n ...(input.category ? { category: input.category } : {}),\n attachments: input.attachments ?? [],\n commentCount: 0,\n authorUid: input.authorUid,\n authorName: input.authorName,\n authorPhoto: input.authorPhoto,\n moderationStatus: 'approved',\n moderationModel: input.moderationModel ?? null,\n createdAt: serverTimestamp(),\n updatedAt: serverTimestamp(),\n }),\n setDoc(bodyDoc(slug), { content: input.content }),\n ])\n\n return slug\n }\n\n async function updatePost(id: string, input: UpdatePostInput): Promise<void> {\n // 메타·본문을 같이 갱신. category 미선택 시 필드 제거(부분 갱신으로 예전 값이 남는 것 방지).\n await Promise.all([\n updateDoc(postDoc(id), {\n title: input.title,\n tags: input.tags ?? [],\n category: input.category ? input.category : deleteField(),\n attachments: input.attachments ?? [],\n updatedAt: serverTimestamp(),\n }),\n setDoc(bodyDoc(id), { content: input.content }, { merge: true }),\n ])\n }\n\n /** 댓글 서브컬렉션 → 본문 → Storage 폴더 → 메타 순으로 삭제한다. */\n async function deletePost(id: string): Promise<void> {\n const commentsSnap = await getDocs(commentsCol(id))\n // Firestore write batch는 최대 500건. 여유를 두고 450건씩 나눠 삭제한다.\n for (let offset = 0; offset < commentsSnap.docs.length; offset += 450) {\n const batch = writeBatch(db)\n commentsSnap.docs.slice(offset, offset + 450).forEach(d => batch.delete(d.ref))\n await batch.commit()\n }\n\n const storage = getStorage(app)\n // Firestore/Storage 규칙이 부모 게시글의 소유권을 조회하므로 부모 문서는 마지막에 삭제한다.\n await deleteDoc(bodyDoc(id))\n await deleteStorageFolder(storageRef(storage, `${prefix()}/posts/${id}`))\n await deleteDoc(postDoc(id))\n }\n\n return { getPosts, getPost, createPost, updatePost, deletePost }\n}\n","import { computed } from 'vue'\nimport type { Ref } from 'vue'\nimport { useCollection, useFirestore } from 'vuefire'\nimport { collection, doc, orderBy, query, serverTimestamp, writeBatch, increment } from 'firebase/firestore'\nimport { useMemiBoardConfig } from '../config'\nimport type { CommentModel } from '../types'\n\nexport interface AddCommentInput {\n body: string\n authorUid: string\n authorName: string | null\n authorPhoto: string | null\n}\n\n/** postId 서브컬렉션 댓글을 실시간(onSnapshot)으로 구독하고, 작성/삭제 시 부모 글의 commentCount를 같은 batch로 증감한다. */\nexport function useMemiBoardComments(postId: string | Ref<string>) {\n const config = useMemiBoardConfig()\n const db = useFirestore()\n const prefix = () => config.collectionPrefix\n\n const id = computed(() => (typeof postId === 'string' ? postId : postId.value))\n\n const commentsQuery = computed(() =>\n query(collection(db, `${prefix()}Posts`, id.value, 'comments'), orderBy('createdAt', 'asc')),\n )\n\n const comments = useCollection<CommentModel>(commentsQuery)\n\n async function addComment(input: AddCommentInput): Promise<void> {\n const batch = writeBatch(db)\n const p = prefix()\n const commentRef = doc(collection(db, `${p}Posts`, id.value, 'comments'))\n batch.set(commentRef, {\n postId: id.value,\n body: input.body,\n authorUid: input.authorUid,\n authorName: input.authorName,\n authorPhoto: input.authorPhoto,\n moderationStatus: 'approved',\n createdAt: serverTimestamp(),\n })\n batch.update(doc(db, `${p}Posts`, id.value), { commentCount: increment(1) })\n await batch.commit()\n }\n\n async function deleteComment(commentId: string): Promise<void> {\n const batch = writeBatch(db)\n const p = prefix()\n batch.delete(doc(db, `${p}Posts`, id.value, 'comments', commentId))\n batch.update(doc(db, `${p}Posts`, id.value), { commentCount: increment(-1) })\n await batch.commit()\n }\n\n return { comments, addComment, deleteComment }\n}\n","export const MODERATION_SYSTEM = `당신은 게시판 콘텐츠를 심사하는 엄격한 모더레이터입니다.\n아래 텍스트에 욕설·비속어·성희롱·혐오·위협·스팸·도배성 광고가 있으면 반드시 거부(flagged=true)합니다.\n\nflagged=true 예:\n- 욕설/비속어: 씨발, 시발, 병신, 지랄, 좆, 개새끼, 존나, fuck, shit, bitch 등\n- 성적 모욕, 혐오 발언, 협박\n- 게시판 주제와 무관한 도배성 광고/스팸\n\nflagged=false:\n- 게시판 주제에 맞는 정상적인 글/댓글 (강한 의견이나 정당한 비판은 허용)\n\nJSON만 출력:\n{\"flagged\":boolean,\"category\":\"none\"|\"abuse\"|\"spam\"|\"adult\"|\"violence\"|\"other\",\"reason\":string}\n- flagged=true면 category는 상황에 맞게 고르고, reason은 한국어 1문장 (예: \"욕설이 포함되어 있어 게시할 수 없습니다.\")\n- flagged=false면 category는 \"none\", reason은 \"\"`\n\n/** API 없이도 1차로 걸러내는 한국어/영어 비속어 (부분 문자열 매칭) */\nexport const DEFAULT_LOCAL_BLOCKLIST = [\n '씨발', '시발', '씨팔', '시팔', '병신', '븅신', '지랄', '좆', '존나',\n '개새끼', '개새', '꺼져', '닥쳐', '씹', '니미', '느금', '애미', '애비',\n 'fuck', 'shit', 'bitch', 'asshole',\n]\n\nexport function buildLocalBlockRegex(patterns: string[]): RegExp {\n return new RegExp(patterns.join('|'), 'i')\n}\n\nconst CATEGORIES = new Set(['none', 'abuse', 'spam', 'adult', 'violence', 'other'])\n\nexport function parseModerationJson(raw: string): { flagged: boolean, category: string, reason: string } | null {\n const trimmed = raw.trim()\n if (!trimmed) return null\n\n let parsed: unknown\n try {\n parsed = JSON.parse(trimmed)\n }\n catch {\n const match = trimmed.match(/\\{[\\s\\S]*\\}/)\n if (!match) return null\n try {\n parsed = JSON.parse(match[0])\n }\n catch {\n return null\n }\n }\n\n if (typeof parsed !== 'object' || parsed === null) return null\n const obj = parsed as Record<string, unknown>\n if (typeof obj.flagged !== 'boolean') return null\n\n const category = typeof obj.category === 'string' && CATEGORIES.has(obj.category) ? obj.category : (obj.flagged ? 'other' : 'none')\n const reason = typeof obj.reason === 'string' ? obj.reason : ''\n return { flagged: obj.flagged, category, reason }\n}\n","import { getAI, getGenerativeModel, GoogleAIBackend, Schema } from 'firebase/ai'\nimport { useFirebaseApp } from 'vuefire'\nimport { buildLocalBlockRegex, DEFAULT_LOCAL_BLOCKLIST, MODERATION_SYSTEM, parseModerationJson } from '../utils/moderation-prompt'\nimport { useMemiBoardConfig } from '../config'\nimport type { ModerationResult, ModerationVia } from '../types'\n\nconst APPROVED: ModerationResult = { flagged: false, category: 'none', reason: '', via: 'empty' }\n\nfunction logModeration(\n phase: string,\n detail: Record<string, unknown>,\n) {\n // 개발·테스트에서 항상 보이게 (프로덕션도 한 줄 info — 필요 시 나중에 dev only)\n console.info(`[memi-board:moderation] ${phase}`, detail)\n}\n\n/**\n * 글쓰기 전 블로킹 검열. 로컬 비속어 필터 → Firebase AI Logic(Gemini).\n * App Check limited-use 토큰 사용.\n */\nexport function useMemiBoardModeration() {\n const config = useMemiBoardConfig()\n const moderation = config.moderation ?? {}\n const app = useFirebaseApp()\n\n const localBlockRe = buildLocalBlockRegex([...DEFAULT_LOCAL_BLOCKLIST, ...(moderation.localBlocklist ?? [])])\n\n function localCheck(text: string): ModerationResult | null {\n const match = text.match(localBlockRe)\n if (match) {\n return {\n flagged: true,\n category: 'abuse',\n reason: '욕설·비속어가 포함되어 게시할 수 없습니다. 표현을 바꿔 주세요.',\n via: 'local',\n }\n }\n return null\n }\n\n function getModerationModel() {\n const modelName = moderation.model ?? 'gemini-2.5-flash'\n const ai = getAI(app, { backend: new GoogleAIBackend(), useLimitedUseAppCheckTokens: true })\n return {\n modelName,\n model: getGenerativeModel(ai, {\n model: modelName,\n systemInstruction: MODERATION_SYSTEM,\n generationConfig: {\n temperature: 0,\n maxOutputTokens: 256,\n responseMimeType: 'application/json',\n responseSchema: Schema.object({\n properties: {\n flagged: Schema.boolean(),\n category: Schema.string(),\n reason: Schema.string(),\n },\n }),\n },\n }),\n }\n }\n\n function errorResult(): ModerationResult {\n if (moderation.onError === 'block') {\n return {\n flagged: true,\n category: 'other',\n reason: '검열 서비스에 일시적으로 연결할 수 없어 제출이 보류되었습니다. 잠시 후 다시 시도해 주세요.',\n error: true,\n via: 'ai-error-block',\n }\n }\n return {\n flagged: false,\n category: 'none',\n reason: '',\n error: true,\n via: 'ai-error-allow',\n }\n }\n\n async function checkText(text: string): Promise<ModerationResult> {\n const trimmed = text.trim()\n const preview = trimmed.slice(0, 80)\n\n logModeration('start', {\n preview,\n length: trimmed.length,\n enabled: moderation.enabled !== false,\n model: moderation.model ?? 'gemini-2.5-flash',\n onError: moderation.onError ?? 'allow',\n })\n\n if (!trimmed) {\n const r = { ...APPROVED, via: 'empty' as ModerationVia }\n logModeration('result', r)\n return r\n }\n\n const local = localCheck(trimmed)\n if (local) {\n const hit = trimmed.match(localBlockRe)?.[0]\n logModeration('local-block', { hit, ...local })\n logModeration('result', local)\n return local\n }\n logModeration('local-pass', { message: '로컬 금칙어 미매칭 → AI Logic 호출' })\n\n if (moderation.enabled === false) {\n const r: ModerationResult = { ...APPROVED, via: 'disabled' }\n logModeration('result', r)\n return r\n }\n\n try {\n const { model, modelName } = getModerationModel()\n logModeration('ai-call', { modelName, useLimitedUseAppCheckTokens: true })\n const t0 = performance.now()\n const result = await model.generateContent(`심사할 텍스트:\\n\"\"\"${trimmed.slice(0, 4000)}\"\"\"`)\n const ms = Math.round(performance.now() - t0)\n const raw = result.response.text()\n logModeration('ai-raw', { ms, raw: raw.slice(0, 500) })\n\n const parsed = parseModerationJson(raw)\n if (!parsed) {\n logModeration('ai-parse-fail', { raw: raw.slice(0, 300) })\n const r = errorResult()\n logModeration('result', r)\n return r\n }\n const out: ModerationResult = {\n flagged: parsed.flagged,\n category: parsed.category as ModerationResult['category'],\n reason: parsed.reason,\n via: 'ai',\n }\n logModeration('result', { ...out, ms })\n return out\n }\n catch (e) {\n logModeration('ai-error', {\n message: e instanceof Error ? e.message : String(e),\n name: e instanceof Error ? e.name : undefined,\n // FirebaseError code 등\n code: (e as { code?: string })?.code,\n })\n const r = errorResult()\n logModeration('result', r)\n return r\n }\n }\n\n async function checkImage(file: File): Promise<ModerationResult> {\n if (moderation.enabled === false || !moderation.moderateImages) {\n return { ...APPROVED, via: 'disabled' }\n }\n\n try {\n const base64 = await new Promise<string>((resolve, reject) => {\n const reader = new FileReader()\n reader.onload = () => resolve((reader.result as string).split(',')[1] ?? '')\n reader.onerror = reject\n reader.readAsDataURL(file)\n })\n\n const { model, modelName } = getModerationModel()\n logModeration('ai-image-call', { modelName, type: file.type, size: file.size })\n const result = await model.generateContent([\n { inlineData: { mimeType: file.type || 'image/jpeg', data: base64 } },\n '이 이미지를 심사해 주세요.',\n ])\n const raw = result.response.text()\n const parsed = parseModerationJson(raw)\n if (!parsed) {\n logModeration('ai-image-parse-fail', { raw: raw.slice(0, 300) })\n return errorResult()\n }\n const out: ModerationResult = {\n flagged: parsed.flagged,\n category: parsed.category as ModerationResult['category'],\n reason: parsed.reason,\n via: 'ai',\n }\n logModeration('ai-image-result', out)\n return out\n }\n catch (e) {\n logModeration('ai-image-error', { message: e instanceof Error ? e.message : String(e) })\n return errorResult()\n }\n }\n\n return { checkText, checkImage }\n}\n","import { getStorage, ref as storageRef, uploadBytesResumable, getDownloadURL, deleteObject } from 'firebase/storage'\nimport type { UploadTask } from 'firebase/storage'\nimport { useFirebaseApp } from 'vuefire'\nimport { safeFileName } from '../utils/slugify'\nimport { useMemiBoardConfig } from '../config'\nimport type { Attachment } from '../types'\n\nexport function useMemiBoardStorage() {\n const config = useMemiBoardConfig()\n const app = useFirebaseApp()\n const prefix = () => config.collectionPrefix\n\n /** postId는 아직 저장되지 않은 새 글이면 임시 id(예: 'new-<timestamp>')를 넘겨도 된다 — 경로 네임스페이스일 뿐이다. */\n function uploadAttachment(\n file: File,\n postId: string,\n onProgress?: (ratio: number) => void,\n ): { promise: Promise<Attachment>, cancel: () => void } {\n const storage = getStorage(app)\n const path = `${prefix()}/posts/${postId}/attachments/${Date.now()}-${safeFileName(file.name)}`\n const fileRef = storageRef(storage, path)\n const task: UploadTask = uploadBytesResumable(fileRef, file, {\n contentType: file.type || 'application/octet-stream',\n contentDisposition: `attachment; filename*=UTF-8''${encodeURIComponent(file.name)}`,\n })\n\n const promise = new Promise<Attachment>((resolve, reject) => {\n task.on(\n 'state_changed',\n (snapshot) => {\n if (onProgress && snapshot.totalBytes > 0) {\n onProgress(snapshot.bytesTransferred / snapshot.totalBytes)\n }\n },\n reject,\n async () => {\n const url = await getDownloadURL(fileRef)\n resolve({ name: file.name, url, path, size: file.size, type: file.type })\n },\n )\n })\n\n return { promise, cancel: () => task.cancel() }\n }\n\n async function deleteAttachment(attachment: Pick<Attachment, 'path'>): Promise<void> {\n const storage = getStorage(app)\n await deleteObject(storageRef(storage, attachment.path)).catch(() => {})\n }\n\n return { uploadAttachment, deleteAttachment }\n}\n","import { computed, watch } from 'vue'\nimport { useFirestore, useDocument } from 'vuefire'\nimport { doc, getDoc, setDoc, updateDoc, serverTimestamp } from 'firebase/firestore'\nimport { useMemiBoardConfig } from '../config'\nimport { useMemiBoardAuth } from './useMemiBoardAuth'\nimport { slugify } from '../utils/slugify'\nimport type { BoardCategory, BoardSettingsModel } from '../types'\n\n/** 설정 문서가 없을 때 UI·시드에 쓰는 기본 카테고리. */\nexport const DEFAULT_CATEGORIES: BoardCategory[] = [\n { id: 'free', label: '자유' },\n { id: 'notice', label: '공지' },\n { id: 'question', label: '질문' },\n]\n\n/**\n * 게시판 설정({prefix}Settings/config) — 카테고리 옵션 목록.\n *\n * - 읽기: 전체 공개 (useDocument 실시간)\n * - 문서 없음: DEFAULT 로 보여 주고, 로그인 사용자가 처음 쓰거나 추가할 때 시드 생성\n * - 카테고리 추가: 로그인 사용자 (글쓰기 폼에서 즉시 추가)\n */\nexport function useMemiBoardSettings() {\n const config = useMemiBoardConfig()\n const db = useFirestore()\n const { isSignedIn, isAdmin } = useMemiBoardAuth()\n\n const settingsRef = computed(() => doc(db, `${config.collectionPrefix}Settings`, 'config'))\n const { data: settingsDoc, pending: settingsPending } = useDocument<BoardSettingsModel>(settingsRef)\n\n const categories = computed<BoardCategory[]>(() => {\n const list = settingsDoc.value?.categories\n return list && list.length > 0 ? list : DEFAULT_CATEGORIES\n })\n\n function categoryLabel(id: string | undefined): string | undefined {\n if (!id) return undefined\n return categories.value.find(c => c.id === id)?.label ?? id\n }\n\n /** 설정 문서가 없으면 기본 카테고리로 한 번 생성. 로그인 필요. */\n async function ensureSettings(): Promise<void> {\n if (!isSignedIn.value) return\n const ref = settingsRef.value\n const snap = await getDoc(ref).catch(() => null)\n if (!snap || snap.exists()) return\n await setDoc(ref, {\n categories: DEFAULT_CATEGORIES,\n updatedAt: serverTimestamp(),\n })\n }\n\n /**\n * 카테고리 추가. label 로 id(slug) 생성, 중복 id 면 접미사.\n * @returns 추가된(또는 기존 동일 label 의) category id\n */\n async function addCategory(label: string): Promise<string> {\n const trimmed = label.trim()\n if (!trimmed) throw new Error('카테고리 이름을 입력해 주세요.')\n if (!isSignedIn.value) throw new Error('로그인이 필요합니다.')\n\n await ensureSettings()\n\n const existing = categories.value.find(\n c => c.label === trimmed || c.id === slugify(trimmed),\n )\n if (existing) return existing.id\n\n let id = slugify(trimmed) || `cat-${Date.now()}`\n const used = new Set(categories.value.map(c => c.id))\n if (used.has(id)) {\n let n = 2\n while (used.has(`${id}-${n}`)) n++\n id = `${id}-${n}`\n }\n\n const next: BoardCategory[] = [...categories.value, { id, label: trimmed }]\n const ref = settingsRef.value\n const snap = await getDoc(ref)\n if (snap.exists()) {\n await updateDoc(ref, {\n categories: next,\n updatedAt: serverTimestamp(),\n })\n }\n else {\n await setDoc(ref, {\n categories: next,\n updatedAt: serverTimestamp(),\n })\n }\n return id\n }\n\n // 관리자 방문 시 문서 없으면 시드 (기존 동작 유지 + 로그인 일반 사용자 ensure 와 병행)\n watch(\n [isAdmin, isSignedIn, settingsPending],\n async ([admin, signedIn, pending]) => {\n if (pending || settingsDoc.value) return\n if (!admin && !signedIn) return\n await ensureSettings().catch(() => {})\n },\n { immediate: true },\n )\n\n return {\n categories,\n categoryLabel,\n settingsPending,\n ensureSettings,\n addCategory,\n DEFAULT_CATEGORIES,\n }\n}\n","export interface VersionHistoryEntry {\n version: string\n date: string\n highlights: string[]\n note?: string\n}\n\n/**\n * MemiBoardVersionHistory 컴포넌트가 그대로 보여주는 데이터.\n * 코드 수준의 자세한 변경 내역은 이 레포의 CHANGELOG.md를 참고 — 새 버전을 릴리스할 때 이 배열도 함께 갱신한다.\n */\nexport const versionHistory: VersionHistoryEntry[] = [\n {\n version: '0.2.2',\n date: '2026-07-31',\n highlights: [\n '카테고리 — 글을 주제별로 분류해서 올릴 수 있어요',\n ],\n },\n {\n version: '0.2.0',\n date: '2026-07-31',\n highlights: [\n '글쓰기 / 수정 / 삭제 — 제목, 본문, 태그를 자유롭게 작성',\n '파일 첨부 — 이미지·문서를 게시글에 첨부, 이미지는 미리보기 제공',\n '댓글 — 실시간으로 댓글을 달고 지울 수 있음',\n '로그인 / 권한 — 로그인해야 글을 쓸 수 있고, 본인 글만 수정·삭제 가능(관리자는 전체 관리)',\n 'AI 자동 검열 — 욕설·부적절한 내용이 담긴 글/댓글은 AI가 자동으로 걸러 게시를 막음',\n '한 줄 설정으로 바로 시작 — pages: true 옵션 하나로 게시판 화면이 바로 만들어짐',\n ],\n note: '서버 없이 동작하는 모듈이라, 실제 보안은 Firebase 프로젝트에 규칙 파일을 등록해야 완성됩니다. README의 Security Model 안내를 꼭 따라주세요.',\n },\n {\n version: '0.1.0',\n date: '2026-07-27',\n highlights: [\n '프로젝트 최초 생성 (아직 게시판 기능은 없는 빈 Nuxt 4 프로젝트)',\n ],\n },\n]\n","import type { Timestamp } from 'firebase/firestore'\n\nexport function formatDate(ts: Timestamp | undefined): string {\n return ts?.toDate ? ts.toDate().toLocaleDateString('ko-KR') : ''\n}\n"],"mappings":";;;;;;;AAoBA,IAAM,IAAa;AAOnB,SAAS,IAAmC;CAC1C,IAAM,IAAI;CAgBV,OAbK,EAAE,OACL,EAAE,KAAc,EAA0B;EACxC,kBAAkB;EAClB,MAAM,EAAE,WAAW,CAAC,UAAU,OAAO,EAAE;EACvC,YAAY;GACV,SAAS;GACT,OAAO;GACP,SAAS;GACT,gBAAgB;GAChB,gBAAgB,CAAC;EACnB;CACF,CAAC,IAEI,EAAE;AACX;AAMA,SAAgB,GAAmB,GAAyC;CAC1E,IAAM,IAAS,EAAgB;CAG/B,AAFI,EAAQ,qBAAkB,EAAO,mBAAmB,EAAQ,mBAC5D,EAAQ,QAAM,OAAO,OAAO,EAAO,MAAM,EAAQ,IAAI,GACrD,EAAQ,cAAY,OAAO,OAAO,EAAO,YAAY,EAAQ,UAAU;AAC7E;AAEA,SAAgB,IAAsC;CACpD,OAAO,EAAgB;AACzB;;;ACvBA,SAAgB,IAA2C;CACzD,IAAM,IAAS,EAAmB,GAC5B,IAAK,EAAa,GAClB,IAAO,EAAgB,GACvB,IAAO,EAAe,GAEtB,IAAO,EAAsB,MAAM,GACnC,IAAc,EAAI,EAAI;CAE5B,SAAS,EAAW,GAAa;EAC/B,OAAO,EAAI,GAAI,GAAG,EAAO,iBAAiB,QAAQ,CAAG;CACvD;CAGA,eAAe,EAAc,GAA+C;EAC1E,IAAM,IAAM,EAAW,EAAa,GAAG,GACjC,IAAO,MAAM,EAAO,CAAG;EAa7B,OAZK,EAAK,OAAO,KAQjB,MAAM,EAAU,GAAK;GACnB,aAAa,EAAa;GAC1B,WAAW,EAAgB;EAC7B,CAAC,GACO,EAAK,KAAK,CAAC,CAAC,QAA6B,WAX/C,MAAM,EAAO,GAAK;GAChB,MAAM;GACN,aAAa,EAAa;GAC1B,WAAW,EAAgB;EAC7B,CAAC,GACM;CAOX;CAEA,EACE,GACA,OAAO,MAAY;EAEjB,IADA,EAAY,QAAQ,IAChB,CAAC,GAAS;GAEZ,AADA,EAAK,QAAQ,QACb,EAAY,QAAQ;GACpB;EACF;EACA,IAAI;GACF,EAAK,QAAQ,MAAM,EAAc,CAAO;EAC1C,QACM;GACJ,EAAK,QAAQ;EACf,UACQ;GACN,EAAY,QAAQ;EACtB;CACF,GACA,EAAE,WAAW,GAAK,CACpB;CAEA,IAAM,IAAa,QAAe,CAAC,CAAC,EAAK,KAAK,GACxC,IAAU,QAAe,EAAK,UAAU,OAAO;CAErD,SAAS,IAAc;EACrB,IAAI,CAAC,GAAM,MAAU,MAAM,0CAA0C;EACrE,OAAO;CACT;CAEA,eAAe,IAAmB;EAChC,OAAO,EAAgB,EAAY,GAAG,IAAI,EAAmB,CAAC;CAChE;CAEA,eAAe,IAAkB;EAC/B,IAAM,IAAW,IAAI,EAAc,WAAW;EAG9C,OAFA,EAAS,SAAS,OAAO,GACzB,EAAS,SAAS,MAAM,GACjB,EAAgB,EAAY,GAAG,CAAQ;CAChD;CAEA,eAAe,EAAgB,GAAe,GAAkB;EAC9D,OAAO,EAA2B,EAAY,GAAG,GAAO,CAAQ;CAClE;CAEA,eAAe,EAAgB,GAAe,GAAkB,GAAsB;EACpF,IAAM,IAAa,MAAM,EAA+B,EAAY,GAAG,GAAO,CAAQ;EAEtF,OADI,KAAa,MAAM,GAAc,EAAW,MAAM,EAAE,eAAY,CAAC,GAC9D;CACT;CAEA,eAAe,IAAU;EACvB,MAAM,EAAgB,EAAY,CAAC;CACrC;CAEA,SAAS,EAAQ,GAAoC;EACnD,OAAO,EAAW,UAAU,EAAK,OAAO,QAAQ,EAAK,aAAa,EAAQ;CAC5E;CAEA,SAAS,EAAiB,GAA0C;EAClE,OAAO,EAAW,UAAU,EAAK,OAAO,QAAQ,EAAQ,aAAa,EAAQ;CAC/E;CAEA,OAAO;EACL;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA,SAAA;EACA;EACA,WAAW;EACX;CACF;AACF;;;AC9IA,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;;;ACUA,eAAe,EAAoB,GAA4C;CAC7E,IAAM,IAAO,MAAM,GAAQ,CAAS,CAAC,CAAC,aAAa;EAAE,OAAO,CAAC;EAAG,UAAU,CAAC;CAAE,EAAE;CAC/E,MAAM,QAAQ,IAAI,CAChB,GAAG,EAAK,MAAM,KAAI,MAAQ,EAAa,CAAI,CAAC,GAC5C,GAAG,EAAK,SAAS,KAAI,MAAU,EAAoB,CAAM,CAAC,CAC5D,CAAC;AACH;AAuBA,SAAgB,IAAoB;CAClC,IAAM,IAAS,EAAmB,GAC5B,IAAK,EAAa,GAClB,IAAM,EAAe,GAErB,UAAe,EAAO,kBAEtB,UAAiB,EAAW,GAAI,GAAG,EAAO,EAAE,MAAM,GAClD,KAAW,MAAe,EAAI,GAAI,GAAG,EAAO,EAAE,QAAQ,CAAE,GACxD,KAAW,MAAe,EAAI,GAAI,GAAG,EAAO,EAAE,QAAQ,GAAI,QAAQ,MAAM,GACxE,KAAe,MAAe,EAAW,GAAI,GAAG,EAAO,EAAE,QAAQ,GAAI,UAAU;CAErF,eAAe,EAAS,IAKpB,CAAC,GAAG;EACN,IAAM,IAAW,EAAK,YAAY,IAC5B,IAAiC,CAAC;EAOxC,AANI,EAAK,YAEP,EAAY,KAAK,EAAM,YAAY,MAAM,EAAK,QAAQ,CAAC,GAEzD,EAAY,KAAK,EAAQ,aAAa,MAAM,CAAC,GACzC,EAAK,UAAQ,EAAY,KAAK,EAAW,EAAK,MAAM,CAAC,GACzD,EAAY,KAAK,EAAQ,IAAW,CAAC,CAAC;EAEtC,IAAM,IAAW,MAAM,EAAQ,EAAM,EAAS,GAAG,GAAG,CAAW,CAAC,GAC1D,IAAO,EAAS,KAAK,MAAM,GAAG,CAAQ;EAC5C,OAAO;GACL,OAAO,EAAK,KAAI,OAAM;IAAE,IAAI,EAAE;IAAI,GAAG,EAAE,KAAK;GAAE,EAAe;GAC7D,QAAQ,EAAK,EAAK,SAAS;GAC3B,SAAS,EAAS,KAAK,SAAS;EAClC;CACF;CAEA,eAAe,EAAQ,GAAwC;EAC7D,IAAM,CAAC,GAAU,KAAY,MAAM,QAAQ,IAAI,CAAC,EAAO,EAAQ,CAAE,CAAC,GAAG,EAAO,EAAQ,CAAE,CAAC,CAAC,CAAC;EAEzF,OADK,EAAS,OAAO,IACd;GACL,IAAI,EAAS;GACb,GAAG,EAAS,KAAK;GACjB,SAAS,EAAS,OAAO,IAAM,EAAS,KAAK,CAAC,CAAyB,WAAW,KAAM;EAC1F,IAL+B;CAMjC;CAEA,eAAe,EAAW,GAAyC;EACjE,IAAM,IAAW,EAAQ,EAAM,KAAK,KAAK,QACrC,IAAO,GACP,IAAU;EACd,QAAQ,MAAM,EAAO,EAAQ,CAAI,CAAC,EAAA,CAAG,OAAO,IAE1C,AADA,KACA,IAAO,GAAG,EAAS,GAAG;EAqBxB,OAlBA,MAAM,QAAQ,IAAI,CAChB,EAAO,EAAQ,CAAI,GAAG;GACpB,OAAO,EAAM;GACb,MAAM,EAAM,QAAQ,CAAC;GACrB,GAAI,EAAM,WAAW,EAAE,UAAU,EAAM,SAAS,IAAI,CAAC;GACrD,aAAa,EAAM,eAAe,CAAC;GACnC,cAAc;GACd,WAAW,EAAM;GACjB,YAAY,EAAM;GAClB,aAAa,EAAM;GACnB,kBAAkB;GAClB,iBAAiB,EAAM,mBAAmB;GAC1C,WAAW,EAAgB;GAC3B,WAAW,EAAgB;EAC7B,CAAC,GACD,EAAO,EAAQ,CAAI,GAAG,EAAE,SAAS,EAAM,QAAQ,CAAC,CAClD,CAAC,GAEM;CACT;CAEA,eAAe,EAAW,GAAY,GAAuC;EAE3E,MAAM,QAAQ,IAAI,CAChB,EAAU,EAAQ,CAAE,GAAG;GACrB,OAAO,EAAM;GACb,MAAM,EAAM,QAAQ,CAAC;GACrB,UAAU,EAAM,WAAW,EAAM,WAAW,EAAY;GACxD,aAAa,EAAM,eAAe,CAAC;GACnC,WAAW,EAAgB;EAC7B,CAAC,GACD,EAAO,EAAQ,CAAE,GAAG,EAAE,SAAS,EAAM,QAAQ,GAAG,EAAE,OAAO,GAAK,CAAC,CACjE,CAAC;CACH;CAGA,eAAe,EAAW,GAA2B;EACnD,IAAM,IAAe,MAAM,EAAQ,EAAY,CAAE,CAAC;EAElD,KAAK,IAAI,IAAS,GAAG,IAAS,EAAa,KAAK,QAAQ,KAAU,KAAK;GACrE,IAAM,IAAQ,EAAW,CAAE;GAE3B,AADA,EAAa,KAAK,MAAM,GAAQ,IAAS,GAAG,CAAC,CAAC,SAAQ,MAAK,EAAM,OAAO,EAAE,GAAG,CAAC,GAC9E,MAAM,EAAM,OAAO;EACrB;EAEA,IAAM,IAAU,EAAW,CAAG;EAI9B,AAFA,MAAM,EAAU,EAAQ,CAAE,CAAC,GAC3B,MAAM,EAAoB,EAAW,GAAS,GAAG,EAAO,EAAE,SAAS,GAAI,CAAC,GACxE,MAAM,EAAU,EAAQ,CAAE,CAAC;CAC7B;CAEA,OAAO;EAAE;EAAU;EAAS;EAAY;EAAY;CAAW;AACjE;;;ACrJA,SAAgB,EAAqB,GAA8B;CACjE,IAAM,IAAS,EAAmB,GAC5B,IAAK,EAAa,GAClB,UAAe,EAAO,kBAEtB,IAAK,QAAgB,OAAO,KAAW,WAAW,IAAS,EAAO,KAAM,GAExE,IAAgB,QACpB,EAAM,EAAW,GAAI,GAAG,EAAO,EAAE,QAAQ,EAAG,OAAO,UAAU,GAAG,EAAQ,aAAa,KAAK,CAAC,CAC7F,GAEM,IAAW,EAA4B,CAAa;CAE1D,eAAe,EAAW,GAAuC;EAC/D,IAAM,IAAQ,EAAW,CAAE,GACrB,IAAI,EAAO,GACX,IAAa,EAAI,EAAW,GAAI,GAAG,EAAE,QAAQ,EAAG,OAAO,UAAU,CAAC;EAWxE,AAVA,EAAM,IAAI,GAAY;GACpB,QAAQ,EAAG;GACX,MAAM,EAAM;GACZ,WAAW,EAAM;GACjB,YAAY,EAAM;GAClB,aAAa,EAAM;GACnB,kBAAkB;GAClB,WAAW,EAAgB;EAC7B,CAAC,GACD,EAAM,OAAO,EAAI,GAAI,GAAG,EAAE,QAAQ,EAAG,KAAK,GAAG,EAAE,cAAc,EAAU,CAAC,EAAE,CAAC,GAC3E,MAAM,EAAM,OAAO;CACrB;CAEA,eAAe,EAAc,GAAkC;EAC7D,IAAM,IAAQ,EAAW,CAAE,GACrB,IAAI,EAAO;EAGjB,AAFA,EAAM,OAAO,EAAI,GAAI,GAAG,EAAE,QAAQ,EAAG,OAAO,YAAY,CAAS,CAAC,GAClE,EAAM,OAAO,EAAI,GAAI,GAAG,EAAE,QAAQ,EAAG,KAAK,GAAG,EAAE,cAAc,EAAU,EAAE,EAAE,CAAC,GAC5E,MAAM,EAAM,OAAO;CACrB;CAEA,OAAO;EAAE;EAAU;EAAY;CAAc;AAC/C;;;ACtDA,IAAa,IAAoB,0iBAiBpB,KAA0B;CACrC;CAAM;CAAM;CAAM;CAAM;CAAM;CAAM;CAAM;CAAK;CAC/C;CAAO;CAAM;CAAM;CAAM;CAAK;CAAM;CAAM;CAAM;CAChD;CAAQ;CAAQ;CAAS;AAC3B;AAEA,SAAgB,GAAqB,GAA4B;CAC/D,OAAO,IAAI,OAAO,EAAS,KAAK,GAAG,GAAG,GAAG;AAC3C;AAEA,IAAM,qBAAa,IAAI,IAAI;CAAC;CAAQ;CAAS;CAAQ;CAAS;CAAY;AAAO,CAAC;AAElF,SAAgB,EAAoB,GAA4E;CAC9G,IAAM,IAAU,EAAI,KAAK;CACzB,IAAI,CAAC,GAAS,OAAO;CAErB,IAAI;CACJ,IAAI;EACF,IAAS,KAAK,MAAM,CAAO;CAC7B,QACM;EACJ,IAAM,IAAQ,EAAQ,MAAM,aAAa;EACzC,IAAI,CAAC,GAAO,OAAO;EACnB,IAAI;GACF,IAAS,KAAK,MAAM,EAAM,EAAE;EAC9B,QACM;GACJ,OAAO;EACT;CACF;CAEA,IAAI,OAAO,KAAW,aAAY,GAAiB,OAAO;CAC1D,IAAM,IAAM;CACZ,IAAI,OAAO,EAAI,WAAY,WAAW,OAAO;CAE7C,IAAM,IAAW,OAAO,EAAI,YAAa,YAAY,GAAW,IAAI,EAAI,QAAQ,IAAI,EAAI,WAAY,EAAI,UAAU,UAAU,QACtH,IAAS,OAAO,EAAI,UAAW,WAAW,EAAI,SAAS;CAC7D,OAAO;EAAE,SAAS,EAAI;EAAS;EAAU;CAAO;AAClD;;;ACjDA,IAAM,IAA6B;CAAE,SAAS;CAAO,UAAU;CAAQ,QAAQ;CAAI,KAAK;AAAQ;AAEhG,SAAS,EACP,GACA,GACA;CAEA,QAAQ,KAAK,2BAA2B,KAAS,CAAM;AACzD;AAMA,SAAgB,IAAyB;CAEvC,IAAM,IADS,EACI,CAAA,CAAO,cAAc,CAAC,GACnC,IAAM,EAAe,GAErB,IAAe,GAAqB,CAAC,GAAG,IAAyB,GAAI,EAAW,kBAAkB,CAAC,CAAE,CAAC;CAE5G,SAAS,EAAW,GAAuC;EAUzD,OATc,EAAK,MAAM,CACrB,IACK;GACL,SAAS;GACT,UAAU;GACV,QAAQ;GACR,KAAK;EACP,IAEK;CACT;CAEA,SAAS,IAAqB;EAC5B,IAAM,IAAY,EAAW,SAAS,oBAChC,IAAK,EAAM,GAAK;GAAE,SAAS,IAAI,EAAgB;GAAG,6BAA6B;EAAK,CAAC;EAC3F,OAAO;GACL;GACA,OAAO,GAAmB,GAAI;IAC5B,OAAO;IACP,mBAAmB;IACnB,kBAAkB;KAChB,aAAa;KACb,iBAAiB;KACjB,kBAAkB;KAClB,gBAAgB,EAAO,OAAO,EAC5B,YAAY;MACV,SAAS,EAAO,QAAQ;MACxB,UAAU,EAAO,OAAO;MACxB,QAAQ,EAAO,OAAO;KACxB,EACF,CAAC;IACH;GACF,CAAC;EACH;CACF;CAEA,SAAS,IAAgC;EAUvC,OATI,EAAW,YAAY,UAClB;GACL,SAAS;GACT,UAAU;GACV,QAAQ;GACR,OAAO;GACP,KAAK;EACP,IAEK;GACL,SAAS;GACT,UAAU;GACV,QAAQ;GACR,OAAO;GACP,KAAK;EACP;CACF;CAEA,eAAe,EAAU,GAAyC;EAChE,IAAM,IAAU,EAAK,KAAK;EAW1B,IARA,EAAc,SAAS;GACrB,SAHc,EAAQ,MAAM,GAAG,EAG/B;GACA,QAAQ,EAAQ;GAChB,SAAS,EAAW,YAAY;GAChC,OAAO,EAAW,SAAS;GAC3B,SAAS,EAAW,WAAW;EACjC,CAAC,GAEG,CAAC,GAAS;GACZ,IAAM,IAAI;IAAE,GAAG;IAAU,KAAK;GAAyB;GAEvD,OADA,EAAc,UAAU,CAAC,GAClB;EACT;EAEA,IAAM,IAAQ,EAAW,CAAO;EAChC,IAAI,GAAO;GACT,IAAM,IAAM,EAAQ,MAAM,CAAY,CAAC,GAAG;GAG1C,OAFA,EAAc,eAAe;IAAE;IAAK,GAAG;GAAM,CAAC,GAC9C,EAAc,UAAU,CAAK,GACtB;EACT;EAGA,IAFA,EAAc,cAAc,EAAE,SAAS,2BAA2B,CAAC,GAE/D,EAAW,YAAY,IAAO;GAChC,IAAM,IAAsB;IAAE,GAAG;IAAU,KAAK;GAAW;GAE3D,OADA,EAAc,UAAU,CAAC,GAClB;EACT;EAEA,IAAI;GACF,IAAM,EAAE,UAAO,iBAAc,EAAmB;GAChD,EAAc,WAAW;IAAE;IAAW,6BAA6B;GAAK,CAAC;GACzE,IAAM,IAAK,YAAY,IAAI,GACrB,IAAS,MAAM,EAAM,gBAAgB,gBAAgB,EAAQ,MAAM,GAAG,GAAI,EAAE,IAAI,GAChF,IAAK,KAAK,MAAM,YAAY,IAAI,IAAI,CAAE,GACtC,IAAM,EAAO,SAAS,KAAK;GACjC,EAAc,UAAU;IAAE;IAAI,KAAK,EAAI,MAAM,GAAG,GAAG;GAAE,CAAC;GAEtD,IAAM,IAAS,EAAoB,CAAG;GACtC,IAAI,CAAC,GAAQ;IACX,EAAc,iBAAiB,EAAE,KAAK,EAAI,MAAM,GAAG,GAAG,EAAE,CAAC;IACzD,IAAM,IAAI,EAAY;IAEtB,OADA,EAAc,UAAU,CAAC,GAClB;GACT;GACA,IAAM,IAAwB;IAC5B,SAAS,EAAO;IAChB,UAAU,EAAO;IACjB,QAAQ,EAAO;IACf,KAAK;GACP;GAEA,OADA,EAAc,UAAU;IAAE,GAAG;IAAK;GAAG,CAAC,GAC/B;EACT,SACO,GAAG;GACR,EAAc,YAAY;IACxB,SAAS,aAAa,QAAQ,EAAE,UAAU,OAAO,CAAC;IAClD,MAAM,aAAa,QAAQ,EAAE,OAAO,KAAA;IAEpC,MAAO,GAAyB;GAClC,CAAC;GACD,IAAM,IAAI,EAAY;GAEtB,OADA,EAAc,UAAU,CAAC,GAClB;EACT;CACF;CAEA,eAAe,EAAW,GAAuC;EAC/D,IAAI,EAAW,YAAY,MAAS,CAAC,EAAW,gBAC9C,OAAO;GAAE,GAAG;GAAU,KAAK;EAAW;EAGxC,IAAI;GACF,IAAM,IAAS,MAAM,IAAI,SAAiB,GAAS,MAAW;IAC5D,IAAM,IAAS,IAAI,WAAW;IAG9B,AAFA,EAAO,eAAe,EAAS,EAAO,OAAkB,MAAM,GAAG,CAAC,CAAC,MAAM,EAAE,GAC3E,EAAO,UAAU,GACjB,EAAO,cAAc,CAAI;GAC3B,CAAC,GAEK,EAAE,UAAO,iBAAc,EAAmB;GAChD,EAAc,iBAAiB;IAAE;IAAW,MAAM,EAAK;IAAM,MAAM,EAAK;GAAK,CAAC;GAK9E,IAAM,KAAM,MAJS,EAAM,gBAAgB,CACzC,EAAE,YAAY;IAAE,UAAU,EAAK,QAAQ;IAAc,MAAM;GAAO,EAAE,GACpE,iBACF,CAAC,EAAA,CACkB,SAAS,KAAK,GAC3B,IAAS,EAAoB,CAAG;GACtC,IAAI,CAAC,GAEH,OADA,EAAc,uBAAuB,EAAE,KAAK,EAAI,MAAM,GAAG,GAAG,EAAE,CAAC,GACxD,EAAY;GAErB,IAAM,IAAwB;IAC5B,SAAS,EAAO;IAChB,UAAU,EAAO;IACjB,QAAQ,EAAO;IACf,KAAK;GACP;GAEA,OADA,EAAc,mBAAmB,CAAG,GAC7B;EACT,SACO,GAAG;GAER,OADA,EAAc,kBAAkB,EAAE,SAAS,aAAa,QAAQ,EAAE,UAAU,OAAO,CAAC,EAAE,CAAC,GAChF,EAAY;EACrB;CACF;CAEA,OAAO;EAAE;EAAW;CAAW;AACjC;;;AC5LA,SAAgB,KAAsB;CACpC,IAAM,IAAS,EAAmB,GAC5B,IAAM,EAAe,GACrB,UAAe,EAAO;CAG5B,SAAS,EACP,GACA,GACA,GACsD;EACtD,IAAM,IAAU,EAAW,CAAG,GACxB,IAAO,GAAG,EAAO,EAAE,SAAS,EAAO,eAAe,KAAK,IAAI,EAAE,GAAG,EAAa,EAAK,IAAI,KACtF,IAAU,EAAW,GAAS,CAAI,GAClC,IAAmB,GAAqB,GAAS,GAAM;GAC3D,aAAa,EAAK,QAAQ;GAC1B,oBAAoB,gCAAgC,mBAAmB,EAAK,IAAI;EAClF,CAAC;EAkBD,OAAO;GAAE,SAAA,IAhBW,SAAqB,GAAS,MAAW;IAC3D,EAAK,GACH,kBACC,MAAa;KACZ,AAAI,KAAc,EAAS,aAAa,KACtC,EAAW,EAAS,mBAAmB,EAAS,UAAU;IAE9D,GACA,GACA,YAAY;KACV,IAAM,IAAM,MAAM,EAAe,CAAO;KACxC,EAAQ;MAAE,MAAM,EAAK;MAAM;MAAK;MAAM,MAAM,EAAK;MAAM,MAAM,EAAK;KAAK,CAAC;IAC1E,CACF;GACF,CAES;GAAS,cAAc,EAAK,OAAO;EAAE;CAChD;CAEA,eAAe,EAAiB,GAAqD;EACnF,IAAM,IAAU,EAAW,CAAG;EAC9B,MAAM,EAAa,EAAW,GAAS,EAAW,IAAI,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC;CACzE;CAEA,OAAO;EAAE;EAAkB;CAAiB;AAC9C;;;AC1CA,IAAa,IAAsC;CACjD;EAAE,IAAI;EAAQ,OAAO;CAAK;CAC1B;EAAE,IAAI;EAAU,OAAO;CAAK;CAC5B;EAAE,IAAI;EAAY,OAAO;CAAK;AAChC;AASA,SAAgB,KAAuB;CACrC,IAAM,IAAS,EAAmB,GAC5B,IAAK,EAAa,GAClB,EAAE,eAAY,eAAY,EAAiB,GAE3C,IAAc,QAAe,EAAI,GAAI,GAAG,EAAO,iBAAiB,WAAW,QAAQ,CAAC,GACpF,EAAE,MAAM,GAAa,SAAS,MAAoB,EAAgC,CAAW,GAE7F,IAAa,QAAgC;EACjD,IAAM,IAAO,EAAY,OAAO;EAChC,OAAO,KAAQ,EAAK,SAAS,IAAI,IAAO;CAC1C,CAAC;CAED,SAAS,EAAc,GAA4C;EAC5D,OACL,OAAO,EAAW,MAAM,MAAK,MAAK,EAAE,OAAO,CAAE,CAAC,EAAE,SAAS;CAC3D;CAGA,eAAe,IAAgC;EAC7C,IAAI,CAAC,EAAW,OAAO;EACvB,IAAM,IAAM,EAAY,OAClB,IAAO,MAAM,EAAO,CAAG,CAAC,CAAC,YAAY,IAAI;EAC3C,CAAC,KAAQ,EAAK,OAAO,KACzB,MAAM,EAAO,GAAK;GAChB,YAAY;GACZ,WAAW,EAAgB;EAC7B,CAAC;CACH;CAMA,eAAe,EAAY,GAAgC;EACzD,IAAM,IAAU,EAAM,KAAK;EAC3B,IAAI,CAAC,GAAS,MAAU,MAAM,mBAAmB;EACjD,IAAI,CAAC,EAAW,OAAO,MAAU,MAAM,aAAa;EAEpD,MAAM,EAAe;EAErB,IAAM,IAAW,EAAW,MAAM,MAChC,MAAK,EAAE,UAAU,KAAW,EAAE,OAAO,EAAQ,CAAO,CACtD;EACA,IAAI,GAAU,OAAO,EAAS;EAE9B,IAAI,IAAK,EAAQ,CAAO,KAAK,OAAO,KAAK,IAAI,KACvC,IAAO,IAAI,IAAI,EAAW,MAAM,KAAI,MAAK,EAAE,EAAE,CAAC;EACpD,IAAI,EAAK,IAAI,CAAE,GAAG;GAChB,IAAI,IAAI;GACR,OAAO,EAAK,IAAI,GAAG,EAAG,GAAG,GAAG,IAAG;GAC/B,IAAK,GAAG,EAAG,GAAG;EAChB;EAEA,IAAM,IAAwB,CAAC,GAAG,EAAW,OAAO;GAAE;GAAI,OAAO;EAAQ,CAAC,GACpE,IAAM,EAAY;EAcxB,QAZI,MADe,EAAO,CAAG,EAAA,CACpB,OAAO,IACd,MAAM,EAAU,GAAK;GACnB,YAAY;GACZ,WAAW,EAAgB;EAC7B,CAAC,IAGD,MAAM,EAAO,GAAK;GAChB,YAAY;GACZ,WAAW,EAAgB;EAC7B,CAAC,GAEI;CACT;CAaA,OAVA,EACE;EAAC;EAAS;EAAY;CAAe,GACrC,OAAO,CAAC,GAAO,GAAU,OAAa;EAChC,KAAW,EAAY,SACvB,CAAC,KAAS,CAAC,KACf,MAAM,EAAe,CAAC,CAAC,YAAY,CAAC,CAAC;CACvC,GACA,EAAE,WAAW,GAAK,CACpB,GAEO;EACL;EACA;EACA;EACA;EACA;EACA;CACF;AACF;;;ACtGA,IAAa,KAAwC;CACnD;EACE,SAAS;EACT,MAAM;EACN,YAAY,CACV,8BACF;CACF;CACA;EACE,SAAS;EACT,MAAM;EACN,YAAY;GACV;GACA;GACA;GACA;GACA;GACA;EACF;EACA,MAAM;CACR;CACA;EACE,SAAS;EACT,MAAM;EACN,YAAY,CACV,0CACF;CACF;AACF;;;ACrCA,SAAgB,GAAW,GAAmC;CAC5D,OAAO,GAAI,SAAS,EAAG,OAAO,CAAC,CAAC,mBAAmB,OAAO,IAAI;AAChE"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../src/config.ts","../src/composables/useMemiBoardAuth.ts","../src/utils/slugify.ts","../src/composables/useMemiBoardPosts.ts","../src/composables/useMemiBoardComments.ts","../src/utils/moderation-prompt.ts","../src/composables/useMemiBoardModeration.ts","../src/composables/useMemiBoardStorage.ts","../src/composables/useMemiBoardSettings.ts","../src/data/versionHistory.ts","../src/utils/formatDate.ts"],"sourcesContent":["import { reactive } from 'vue'\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 /** AI Logic App Check limited-use 토큰 (재생 보호). 기본 true */\n useLimitedUseAppCheckTokens?: boolean\n}\n\nexport interface MemiBoardConfig {\n collectionPrefix: string\n auth: MemiBoardAuthOptions\n moderation: MemiBoardModerationOptions\n}\n\nconst GLOBAL_KEY = '__MEMI_BOARD_CONFIG__' as const\n\n/**\n * link:/file: 모노레포에서 Vite 가 dist 와 src 를 서로 다른 모듈 id 로 묶으면\n * 모듈 스코프 singleton 이 둘로 갈라져 configure 가 안 먹는 문제가 난다.\n * globalThis 에 한 번만 올려 플러그인·컴포넌트가 항상 같은 config 를 보게 한다.\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 collectionPrefix: 'memiBoard',\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 },\n })\n }\n return g[GLOBAL_KEY]\n}\n\n/**\n * 앱 부팅 시(플러그인 등에서) 한 번 호출한다. 모든 요청/세션이 같은 값을 보므로\n * 서버 렌더링에서도 안전하다 — 사용자별/요청별로 달라지는 값이 아니다.\n */\nexport function configureMemiBoard(options: Partial<MemiBoardConfig>): void {\n const config = getSharedConfig()\n if (options.collectionPrefix) config.collectionPrefix = options.collectionPrefix\n if (options.auth) Object.assign(config.auth, options.auth)\n if (options.moderation) Object.assign(config.moderation, options.moderation)\n}\n\nexport function useMemiBoardConfig(): MemiBoardConfig {\n return getSharedConfig()\n}\n","import { ref, computed, watch } from 'vue'\nimport type { Ref, ComputedRef } from 'vue'\nimport { useFirestore, useFirebaseAuth, useCurrentUser } from 'vuefire'\nimport { doc, getDoc, setDoc, updateDoc, serverTimestamp } from 'firebase/firestore'\nimport {\n GoogleAuthProvider,\n OAuthProvider,\n signInWithPopup,\n signInWithEmailAndPassword,\n createUserWithEmailAndPassword,\n signOut as firebaseSignOut,\n updateProfile,\n} from 'firebase/auth'\nimport type { User } from 'firebase/auth'\nimport { useMemiBoardConfig } from '../config'\nimport type { PostModel, CommentModel } from '../types'\n\nexport interface UseMemiBoardAuthReturn {\n user: ReturnType<typeof useCurrentUser>\n isSignedIn: ComputedRef<boolean>\n isAdmin: ComputedRef<boolean>\n rolePending: Ref<boolean>\n signInWithGoogle: () => ReturnType<typeof signInWithPopup>\n signInWithApple: () => ReturnType<typeof signInWithPopup>\n signInWithEmail: (email: string, password: string) => ReturnType<typeof signInWithEmailAndPassword>\n signUpWithEmail: (email: string, password: string, displayName?: string) => ReturnType<typeof createUserWithEmailAndPassword>\n signOut: () => Promise<void>\n canEdit: (post: Pick<PostModel, 'authorUid'>) => boolean\n canDelete: (post: Pick<PostModel, 'authorUid'>) => boolean\n canDeleteComment: (comment: Pick<CommentModel, 'authorUid'>) => boolean\n}\n\n/**\n * 인증 상태 + 게시판 역할(role) 관리.\n * role은 {prefix}Users/{uid} 문서에서만 읽고, 모듈은 절대 'admin'을 쓰지 않는다\n * (관리자 승격은 호스트 프로젝트 소유자가 Firebase 콘솔에서 직접 설정).\n */\nexport function useMemiBoardAuth(): UseMemiBoardAuthReturn {\n const config = useMemiBoardConfig()\n const db = useFirestore()\n const auth = useFirebaseAuth()\n const user = useCurrentUser()\n\n const role = ref<'user' | 'admin'>('user')\n const rolePending = ref(true)\n\n function roleDocRef(uid: string) {\n return doc(db, `${config.collectionPrefix}Users`, uid)\n }\n\n /** 최초 로그인 시 역할 문서를 생성(role: 'user' 고정)하고, 이후에는 표시용 정보만 갱신한다. */\n async function ensureUserDoc(firebaseUser: User): Promise<'user' | 'admin'> {\n const ref = roleDocRef(firebaseUser.uid)\n const snap = await getDoc(ref)\n if (!snap.exists()) {\n await setDoc(ref, {\n role: 'user',\n displayName: firebaseUser.displayName,\n updatedAt: serverTimestamp(),\n })\n return 'user'\n }\n await updateDoc(ref, {\n displayName: firebaseUser.displayName,\n updatedAt: serverTimestamp(),\n })\n return (snap.data().role as 'user' | 'admin') ?? 'user'\n }\n\n watch(\n user,\n async (current) => {\n rolePending.value = true\n if (!current) {\n role.value = 'user'\n rolePending.value = false\n return\n }\n try {\n role.value = await ensureUserDoc(current)\n }\n catch {\n role.value = 'user'\n }\n finally {\n rolePending.value = false\n }\n },\n { immediate: true },\n )\n\n const isSignedIn = computed(() => !!user.value)\n const isAdmin = computed(() => role.value === 'admin')\n\n function requireAuth() {\n if (!auth) throw new Error('[memi-board] Firebase Auth가 초기화되지 않았습니다.')\n return auth\n }\n\n async function signInWithGoogle() {\n return signInWithPopup(requireAuth(), new GoogleAuthProvider())\n }\n\n async function signInWithApple() {\n const provider = new OAuthProvider('apple.com')\n provider.addScope('email')\n provider.addScope('name')\n return signInWithPopup(requireAuth(), provider)\n }\n\n async function signInWithEmail(email: string, password: string) {\n return signInWithEmailAndPassword(requireAuth(), email, password)\n }\n\n async function signUpWithEmail(email: string, password: string, displayName?: string) {\n const credential = await createUserWithEmailAndPassword(requireAuth(), email, password)\n if (displayName) await updateProfile(credential.user, { displayName })\n return credential\n }\n\n async function signOut() {\n await firebaseSignOut(requireAuth())\n }\n\n function canEdit(post: Pick<PostModel, 'authorUid'>) {\n return isSignedIn.value && (user.value?.uid === post.authorUid || isAdmin.value)\n }\n\n function canDeleteComment(comment: Pick<CommentModel, 'authorUid'>) {\n return isSignedIn.value && (user.value?.uid === comment.authorUid || isAdmin.value)\n }\n\n return {\n user,\n isSignedIn,\n isAdmin,\n rolePending,\n signInWithGoogle,\n signInWithApple,\n signInWithEmail,\n signUpWithEmail,\n signOut,\n canEdit,\n canDelete: canEdit,\n canDeleteComment,\n }\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","import {\n collection,\n doc,\n getDoc,\n getDocs,\n setDoc,\n updateDoc,\n deleteDoc,\n query,\n where,\n orderBy,\n limit as fbLimit,\n startAfter,\n serverTimestamp,\n writeBatch,\n deleteField,\n} from 'firebase/firestore'\nimport type { QueryConstraint } from 'firebase/firestore'\nimport type { DocumentData, QueryDocumentSnapshot } from 'firebase/firestore'\nimport { getStorage, ref as storageRef, listAll, deleteObject } from 'firebase/storage'\nimport type { StorageReference } from 'firebase/storage'\nimport { useFirebaseApp, useFirestore } from 'vuefire'\nimport { slugify } from '../utils/slugify'\nimport { useMemiBoardConfig } from '../config'\nimport type { Attachment, PostDetail, PostModel } from '../types'\n\nasync function deleteStorageFolder(folderRef: StorageReference): Promise<void> {\n const list = await listAll(folderRef).catch(() => ({ items: [], prefixes: [] }))\n await Promise.all([\n ...list.items.map(item => deleteObject(item)),\n ...list.prefixes.map(prefix => deleteStorageFolder(prefix)),\n ])\n}\n\nexport interface CreatePostInput {\n title: string\n content: string\n tags?: string[]\n category?: string\n attachments?: Attachment[]\n authorUid: string\n authorName: string | null\n authorPhoto: string | null\n moderationModel?: string\n}\n\nexport interface UpdatePostInput {\n title: string\n content: string\n tags?: string[]\n category?: string\n attachments?: Attachment[]\n}\n\n/** 목록 조회는 posts/{id} 메타만 읽고, 본문(body/main)은 상세 조회 시에만 읽는다. */\nexport function useMemiBoardPosts() {\n const config = useMemiBoardConfig()\n const db = useFirestore()\n const app = useFirebaseApp()\n // prefix 는 setup 시점에 고정하지 않는다 (configure 이전 호출·이중 인스턴스 대비)\n const prefix = () => config.collectionPrefix\n\n const postsCol = () => collection(db, `${prefix()}Posts`)\n const postDoc = (id: string) => doc(db, `${prefix()}Posts`, id)\n const bodyDoc = (id: string) => doc(db, `${prefix()}Posts`, id, 'body', 'main')\n const commentsCol = (id: string) => collection(db, `${prefix()}Posts`, id, 'comments')\n\n async function getPosts(opts: {\n pageSize?: number\n cursor?: QueryDocumentSnapshot<DocumentData>\n /** 지정 시 해당 카테고리 글만 (boardSettings 의 category id) */\n category?: string\n } = {}) {\n const pageSize = opts.pageSize ?? 20\n const constraints: QueryConstraint[] = []\n if (opts.category) {\n // 복합 인덱스: category ASC + createdAt DESC (호스트 firestore.indexes.json)\n constraints.push(where('category', '==', opts.category))\n }\n constraints.push(orderBy('createdAt', 'desc'))\n if (opts.cursor) constraints.push(startAfter(opts.cursor))\n constraints.push(fbLimit(pageSize + 1))\n\n const snapshot = await getDocs(query(postsCol(), ...constraints))\n const docs = snapshot.docs.slice(0, pageSize)\n return {\n posts: docs.map(d => ({ id: d.id, ...d.data() }) as PostModel),\n cursor: docs[docs.length - 1] as QueryDocumentSnapshot<DocumentData> | undefined,\n hasMore: snapshot.docs.length > pageSize,\n }\n }\n\n async function getPost(id: string): Promise<PostDetail | null> {\n const [metaSnap, bodySnap] = await Promise.all([getDoc(postDoc(id)), getDoc(bodyDoc(id))])\n if (!metaSnap.exists()) return null\n return {\n id: metaSnap.id,\n ...metaSnap.data(),\n content: bodySnap.exists() ? ((bodySnap.data() as { content: string }).content ?? '') : '',\n } as PostDetail\n }\n\n async function createPost(input: CreatePostInput): Promise<string> {\n const baseSlug = slugify(input.title) || 'post'\n let slug = baseSlug\n let counter = 1\n while ((await getDoc(postDoc(slug))).exists()) {\n counter++\n slug = `${baseSlug}-${counter}`\n }\n\n await Promise.all([\n setDoc(postDoc(slug), {\n title: input.title,\n tags: input.tags ?? [],\n ...(input.category ? { category: input.category } : {}),\n attachments: input.attachments ?? [],\n commentCount: 0,\n authorUid: input.authorUid,\n authorName: input.authorName,\n authorPhoto: input.authorPhoto,\n moderationStatus: 'approved',\n moderationModel: input.moderationModel ?? null,\n createdAt: serverTimestamp(),\n updatedAt: serverTimestamp(),\n }),\n setDoc(bodyDoc(slug), { content: input.content }),\n ])\n\n return slug\n }\n\n async function updatePost(id: string, input: UpdatePostInput): Promise<void> {\n // 메타·본문을 같이 갱신. category 미선택 시 필드 제거(부분 갱신으로 예전 값이 남는 것 방지).\n await Promise.all([\n updateDoc(postDoc(id), {\n title: input.title,\n tags: input.tags ?? [],\n category: input.category ? input.category : deleteField(),\n attachments: input.attachments ?? [],\n updatedAt: serverTimestamp(),\n }),\n setDoc(bodyDoc(id), { content: input.content }, { merge: true }),\n ])\n }\n\n /** 댓글 서브컬렉션 → 본문 → Storage 폴더 → 메타 순으로 삭제한다. */\n async function deletePost(id: string): Promise<void> {\n const commentsSnap = await getDocs(commentsCol(id))\n // Firestore write batch는 최대 500건. 여유를 두고 450건씩 나눠 삭제한다.\n for (let offset = 0; offset < commentsSnap.docs.length; offset += 450) {\n const batch = writeBatch(db)\n commentsSnap.docs.slice(offset, offset + 450).forEach(d => batch.delete(d.ref))\n await batch.commit()\n }\n\n const storage = getStorage(app)\n // Firestore/Storage 규칙이 부모 게시글의 소유권을 조회하므로 부모 문서는 마지막에 삭제한다.\n await deleteDoc(bodyDoc(id))\n await deleteStorageFolder(storageRef(storage, `${prefix()}/posts/${id}`))\n await deleteDoc(postDoc(id))\n }\n\n return { getPosts, getPost, createPost, updatePost, deletePost }\n}\n","import { computed } from 'vue'\nimport type { Ref } from 'vue'\nimport { useCollection, useFirestore } from 'vuefire'\nimport { collection, doc, orderBy, query, serverTimestamp, writeBatch, increment } from 'firebase/firestore'\nimport { useMemiBoardConfig } from '../config'\nimport type { CommentModel } from '../types'\n\nexport interface AddCommentInput {\n body: string\n authorUid: string\n authorName: string | null\n authorPhoto: string | null\n}\n\n/** postId 서브컬렉션 댓글을 실시간(onSnapshot)으로 구독하고, 작성/삭제 시 부모 글의 commentCount를 같은 batch로 증감한다. */\nexport function useMemiBoardComments(postId: string | Ref<string>) {\n const config = useMemiBoardConfig()\n const db = useFirestore()\n const prefix = () => config.collectionPrefix\n\n const id = computed(() => (typeof postId === 'string' ? postId : postId.value))\n\n const commentsQuery = computed(() =>\n query(collection(db, `${prefix()}Posts`, id.value, 'comments'), orderBy('createdAt', 'asc')),\n )\n\n const comments = useCollection<CommentModel>(commentsQuery)\n\n async function addComment(input: AddCommentInput): Promise<void> {\n const batch = writeBatch(db)\n const p = prefix()\n const commentRef = doc(collection(db, `${p}Posts`, id.value, 'comments'))\n batch.set(commentRef, {\n postId: id.value,\n body: input.body,\n authorUid: input.authorUid,\n authorName: input.authorName,\n authorPhoto: input.authorPhoto,\n moderationStatus: 'approved',\n createdAt: serverTimestamp(),\n })\n batch.update(doc(db, `${p}Posts`, id.value), { commentCount: increment(1) })\n await batch.commit()\n }\n\n async function deleteComment(commentId: string): Promise<void> {\n const batch = writeBatch(db)\n const p = prefix()\n batch.delete(doc(db, `${p}Posts`, id.value, 'comments', commentId))\n batch.update(doc(db, `${p}Posts`, id.value), { commentCount: increment(-1) })\n await batch.commit()\n }\n\n return { comments, addComment, deleteComment }\n}\n","export const MODERATION_SYSTEM = `당신은 게시판 콘텐츠를 심사하는 엄격한 모더레이터입니다.\n아래 텍스트에 욕설·비속어·성희롱·혐오·위협·스팸·도배성 광고가 있으면 반드시 거부(flagged=true)합니다.\n\nflagged=true 예:\n- 욕설/비속어: 씨발, 시발, 병신, 지랄, 좆, 개새끼, 존나, fuck, shit, bitch 등\n- 성적 모욕, 혐오 발언, 협박\n- 게시판 주제와 무관한 도배성 광고/스팸\n\nflagged=false:\n- 게시판 주제에 맞는 정상적인 글/댓글 (강한 의견이나 정당한 비판은 허용)\n\nJSON만 출력:\n{\"flagged\":boolean,\"category\":\"none\"|\"abuse\"|\"spam\"|\"adult\"|\"violence\"|\"other\",\"reason\":string}\n- flagged=true면 category는 상황에 맞게 고르고, reason은 한국어 1문장 (예: \"욕설이 포함되어 있어 게시할 수 없습니다.\")\n- flagged=false면 category는 \"none\", reason은 \"\"`\n\n/** API 없이도 1차로 걸러내는 한국어/영어 비속어 (부분 문자열 매칭) */\nexport const DEFAULT_LOCAL_BLOCKLIST = [\n '씨발', '시발', '시이발', '씨팔', '시팔', '병신', '븅신', '지랄', '좆', '존나',\n '개새끼', '개새', '새끼', '꺼져', '닥쳐', '씹', '니미', '느금', '애미', '애비',\n 'fuck', 'shit', 'bitch', 'asshole',\n]\n\nexport function buildLocalBlockRegex(patterns: string[]): RegExp {\n return new RegExp(patterns.join('|'), 'i')\n}\n\nconst CATEGORIES = new Set(['none', 'abuse', 'spam', 'adult', 'violence', 'other'])\n\nexport function parseModerationJson(raw: string): { flagged: boolean, category: string, reason: string } | null {\n const trimmed = raw.trim()\n if (!trimmed) return null\n\n let parsed: unknown\n try {\n parsed = JSON.parse(trimmed)\n }\n catch {\n const match = trimmed.match(/\\{[\\s\\S]*\\}/)\n if (!match) return null\n try {\n parsed = JSON.parse(match[0])\n }\n catch {\n return null\n }\n }\n\n if (typeof parsed !== 'object' || parsed === null) return null\n const obj = parsed as Record<string, unknown>\n if (typeof obj.flagged !== 'boolean') return null\n\n const category = typeof obj.category === 'string' && CATEGORIES.has(obj.category) ? obj.category : (obj.flagged ? 'other' : 'none')\n const reason = typeof obj.reason === 'string' ? obj.reason : ''\n return { flagged: obj.flagged, category, reason }\n}\n","import { getAI, getGenerativeModel, GoogleAIBackend, Schema } from 'firebase/ai'\nimport { useFirebaseApp } from 'vuefire'\nimport { buildLocalBlockRegex, DEFAULT_LOCAL_BLOCKLIST, MODERATION_SYSTEM, parseModerationJson } from '../utils/moderation-prompt'\nimport { useMemiBoardConfig } from '../config'\nimport type { ModerationResult, ModerationVia } from '../types'\n\nconst APPROVED: ModerationResult = { flagged: false, category: 'none', reason: '', via: 'empty' }\n\nfunction logModeration(\n phase: string,\n detail: Record<string, unknown> | object,\n) {\n // 개발·테스트에서 항상 보이게 (프로덕션도 한 줄 info — 필요 시 나중에 dev only)\n console.info(`[memi-board:moderation] ${phase}`, detail)\n}\n\n/**\n * 글쓰기 전 블로킹 검열. 로컬 비속어 필터 → Firebase AI Logic(Gemini).\n * App Check limited-use 토큰 사용.\n */\nexport function useMemiBoardModeration() {\n const config = useMemiBoardConfig()\n const moderation = config.moderation ?? {}\n const app = useFirebaseApp()\n\n const localBlockRe = buildLocalBlockRegex([...DEFAULT_LOCAL_BLOCKLIST, ...(moderation.localBlocklist ?? [])])\n\n function localCheck(text: string): ModerationResult | null {\n const match = text.match(localBlockRe)\n if (match) {\n return {\n flagged: true,\n category: 'abuse',\n reason: '욕설·비속어가 포함되어 게시할 수 없습니다. 표현을 바꿔 주세요.',\n via: 'local',\n }\n }\n return null\n }\n\n function getModerationModel() {\n const modelName = moderation.model ?? 'gemini-3.5-flash-lite'\n // limited-use 는 재생 보호용. 기본 App Check 세션 토큰으로도 AI Logic 호출 가능.\n // (App Check 미통과 시 limited-use 도 동일하게 실패 — 디버그 토큰/reCAPTCHA 가 우선)\n const useLimited = moderation.useLimitedUseAppCheckTokens !== false\n const ai = getAI(app, {\n backend: new GoogleAIBackend(),\n useLimitedUseAppCheckTokens: useLimited,\n })\n return {\n modelName,\n useLimited,\n model: getGenerativeModel(ai, {\n model: modelName,\n systemInstruction: MODERATION_SYSTEM,\n generationConfig: {\n temperature: 0,\n maxOutputTokens: 256,\n responseMimeType: 'application/json',\n responseSchema: Schema.object({\n properties: {\n flagged: Schema.boolean(),\n category: Schema.string(),\n reason: Schema.string(),\n },\n }),\n },\n }),\n }\n }\n\n function errorResult(): ModerationResult {\n if (moderation.onError === 'block') {\n return {\n flagged: true,\n category: 'other',\n reason:\n 'AI 검열에 실패해 글을 등록할 수 없습니다. (App Check/네트워크 확인) 잠시 후 다시 시도해 주세요.',\n error: true,\n via: 'ai-error-block',\n }\n }\n return {\n flagged: false,\n category: 'none',\n reason: '',\n error: true,\n via: 'ai-error-allow',\n }\n }\n\n async function checkText(text: string): Promise<ModerationResult> {\n const trimmed = text.trim()\n const preview = trimmed.slice(0, 80)\n\n logModeration('start', {\n preview,\n length: trimmed.length,\n enabled: moderation.enabled !== false,\n model: moderation.model ?? 'gemini-3.5-flash-lite',\n onError: moderation.onError ?? 'allow',\n })\n\n if (!trimmed) {\n const r = { ...APPROVED, via: 'empty' as ModerationVia }\n logModeration('result', r)\n return r\n }\n\n const local = localCheck(trimmed)\n if (local) {\n const hit = trimmed.match(localBlockRe)?.[0]\n logModeration('local-block', { hit, ...local })\n logModeration('result', local)\n return local\n }\n logModeration('local-pass', { message: '로컬 금칙어 미매칭 → AI Logic 호출' })\n\n if (moderation.enabled === false) {\n const r: ModerationResult = { ...APPROVED, via: 'disabled' }\n logModeration('result', r)\n return r\n }\n\n try {\n const { model, modelName, useLimited } = getModerationModel()\n logModeration('ai-call', { modelName, useLimitedUseAppCheckTokens: useLimited })\n const t0 = performance.now()\n const result = await model.generateContent(`심사할 텍스트:\\n\"\"\"${trimmed.slice(0, 4000)}\"\"\"`)\n const ms = Math.round(performance.now() - t0)\n const raw = result.response.text()\n logModeration('ai-raw', { ms, raw: raw.slice(0, 500) })\n\n const parsed = parseModerationJson(raw)\n if (!parsed) {\n logModeration('ai-parse-fail', { raw: raw.slice(0, 300) })\n const r = errorResult()\n logModeration('result', r)\n return r\n }\n const out: ModerationResult = {\n flagged: parsed.flagged,\n category: parsed.category as ModerationResult['category'],\n reason: parsed.reason,\n via: 'ai',\n }\n logModeration('result', { ...out, ms })\n return out\n }\n catch (e) {\n const err = e as { code?: string, customData?: unknown, cause?: unknown }\n logModeration('ai-error', {\n message: e instanceof Error ? e.message : String(e),\n name: e instanceof Error ? e.name : undefined,\n code: err?.code,\n // 모델 폐기/App Check 등 원인 파악용 (전체 메시지에 이미 포함되는 경우 많음)\n cause: err?.cause instanceof Error ? err.cause.message : err?.cause,\n })\n const r = errorResult()\n logModeration('result', r)\n return r\n }\n }\n\n async function checkImage(file: File): Promise<ModerationResult> {\n if (moderation.enabled === false || !moderation.moderateImages) {\n return { ...APPROVED, via: 'disabled' }\n }\n\n try {\n const base64 = await new Promise<string>((resolve, reject) => {\n const reader = new FileReader()\n reader.onload = () => resolve((reader.result as string).split(',')[1] ?? '')\n reader.onerror = reject\n reader.readAsDataURL(file)\n })\n\n const { model, modelName } = getModerationModel()\n logModeration('ai-image-call', { modelName, type: file.type, size: file.size })\n const result = await model.generateContent([\n { inlineData: { mimeType: file.type || 'image/jpeg', data: base64 } },\n '이 이미지를 심사해 주세요.',\n ])\n const raw = result.response.text()\n const parsed = parseModerationJson(raw)\n if (!parsed) {\n logModeration('ai-image-parse-fail', { raw: raw.slice(0, 300) })\n return errorResult()\n }\n const out: ModerationResult = {\n flagged: parsed.flagged,\n category: parsed.category as ModerationResult['category'],\n reason: parsed.reason,\n via: 'ai',\n }\n logModeration('ai-image-result', out)\n return out\n }\n catch (e) {\n logModeration('ai-image-error', { message: e instanceof Error ? e.message : String(e) })\n return errorResult()\n }\n }\n\n return { checkText, checkImage }\n}\n","import { getStorage, ref as storageRef, uploadBytesResumable, getDownloadURL, deleteObject } from 'firebase/storage'\nimport type { UploadTask } from 'firebase/storage'\nimport { useFirebaseApp } from 'vuefire'\nimport { safeFileName } from '../utils/slugify'\nimport { useMemiBoardConfig } from '../config'\nimport type { Attachment } from '../types'\n\nexport function useMemiBoardStorage() {\n const config = useMemiBoardConfig()\n const app = useFirebaseApp()\n const prefix = () => config.collectionPrefix\n\n /** postId는 아직 저장되지 않은 새 글이면 임시 id(예: 'new-<timestamp>')를 넘겨도 된다 — 경로 네임스페이스일 뿐이다. */\n function uploadAttachment(\n file: File,\n postId: string,\n onProgress?: (ratio: number) => void,\n ): { promise: Promise<Attachment>, cancel: () => void } {\n const storage = getStorage(app)\n const path = `${prefix()}/posts/${postId}/attachments/${Date.now()}-${safeFileName(file.name)}`\n const fileRef = storageRef(storage, path)\n const task: UploadTask = uploadBytesResumable(fileRef, file, {\n contentType: file.type || 'application/octet-stream',\n contentDisposition: `attachment; filename*=UTF-8''${encodeURIComponent(file.name)}`,\n })\n\n const promise = new Promise<Attachment>((resolve, reject) => {\n task.on(\n 'state_changed',\n (snapshot) => {\n if (onProgress && snapshot.totalBytes > 0) {\n onProgress(snapshot.bytesTransferred / snapshot.totalBytes)\n }\n },\n reject,\n async () => {\n const url = await getDownloadURL(fileRef)\n resolve({ name: file.name, url, path, size: file.size, type: file.type })\n },\n )\n })\n\n return { promise, cancel: () => task.cancel() }\n }\n\n async function deleteAttachment(attachment: Pick<Attachment, 'path'>): Promise<void> {\n const storage = getStorage(app)\n await deleteObject(storageRef(storage, attachment.path)).catch(() => {})\n }\n\n return { uploadAttachment, deleteAttachment }\n}\n","import { computed, watch } from 'vue'\nimport { useFirestore, useDocument } from 'vuefire'\nimport { doc, getDoc, setDoc, updateDoc, serverTimestamp } from 'firebase/firestore'\nimport { useMemiBoardConfig } from '../config'\nimport { useMemiBoardAuth } from './useMemiBoardAuth'\nimport { slugify } from '../utils/slugify'\nimport type { BoardCategory, BoardSettingsModel } from '../types'\n\n/** 설정 문서가 없을 때 UI·시드에 쓰는 기본 카테고리. */\nexport const DEFAULT_CATEGORIES: BoardCategory[] = [\n { id: 'free', label: '자유' },\n { id: 'notice', label: '공지' },\n { id: 'question', label: '질문' },\n]\n\n/**\n * 게시판 설정({prefix}Settings/config) — 카테고리 옵션 목록.\n *\n * - 읽기: 전체 공개 (useDocument 실시간)\n * - 문서 없음: DEFAULT 로 보여 주고, 로그인 사용자가 처음 쓰거나 추가할 때 시드 생성\n * - 카테고리 추가: 로그인 사용자 (글쓰기 폼에서 즉시 추가)\n */\nexport function useMemiBoardSettings() {\n const config = useMemiBoardConfig()\n const db = useFirestore()\n const { isSignedIn, isAdmin } = useMemiBoardAuth()\n\n const settingsRef = computed(() => doc(db, `${config.collectionPrefix}Settings`, 'config'))\n const { data: settingsDoc, pending: settingsPending } = useDocument<BoardSettingsModel>(settingsRef)\n\n const categories = computed<BoardCategory[]>(() => {\n const list = settingsDoc.value?.categories\n return list && list.length > 0 ? list : DEFAULT_CATEGORIES\n })\n\n function categoryLabel(id: string | undefined): string | undefined {\n if (!id) return undefined\n return categories.value.find(c => c.id === id)?.label ?? id\n }\n\n /** 설정 문서가 없으면 기본 카테고리로 한 번 생성. 로그인 필요. */\n async function ensureSettings(): Promise<void> {\n if (!isSignedIn.value) return\n const ref = settingsRef.value\n const snap = await getDoc(ref).catch(() => null)\n if (!snap || snap.exists()) return\n await setDoc(ref, {\n categories: DEFAULT_CATEGORIES,\n updatedAt: serverTimestamp(),\n })\n }\n\n /**\n * 카테고리 추가. label 로 id(slug) 생성, 중복 id 면 접미사.\n * @returns 추가된(또는 기존 동일 label 의) category id\n */\n async function addCategory(label: string): Promise<string> {\n const trimmed = label.trim()\n if (!trimmed) throw new Error('카테고리 이름을 입력해 주세요.')\n if (!isSignedIn.value) throw new Error('로그인이 필요합니다.')\n\n await ensureSettings()\n\n const existing = categories.value.find(\n c => c.label === trimmed || c.id === slugify(trimmed),\n )\n if (existing) return existing.id\n\n let id = slugify(trimmed) || `cat-${Date.now()}`\n const used = new Set(categories.value.map(c => c.id))\n if (used.has(id)) {\n let n = 2\n while (used.has(`${id}-${n}`)) n++\n id = `${id}-${n}`\n }\n\n const next: BoardCategory[] = [...categories.value, { id, label: trimmed }]\n const ref = settingsRef.value\n const snap = await getDoc(ref)\n if (snap.exists()) {\n await updateDoc(ref, {\n categories: next,\n updatedAt: serverTimestamp(),\n })\n }\n else {\n await setDoc(ref, {\n categories: next,\n updatedAt: serverTimestamp(),\n })\n }\n return id\n }\n\n // 관리자 방문 시 문서 없으면 시드 (기존 동작 유지 + 로그인 일반 사용자 ensure 와 병행)\n watch(\n [isAdmin, isSignedIn, settingsPending],\n async ([admin, signedIn, pending]) => {\n if (pending || settingsDoc.value) return\n if (!admin && !signedIn) return\n await ensureSettings().catch(() => {})\n },\n { immediate: true },\n )\n\n return {\n categories,\n categoryLabel,\n settingsPending,\n ensureSettings,\n addCategory,\n DEFAULT_CATEGORIES,\n }\n}\n","export interface VersionHistoryEntry {\n version: string\n date: string\n highlights: string[]\n note?: string\n}\n\n/**\n * MemiBoardVersionHistory 컴포넌트가 그대로 보여주는 데이터.\n * 코드 수준의 자세한 변경 내역은 이 레포의 CHANGELOG.md를 참고 — 새 버전을 릴리스할 때 이 배열도 함께 갱신한다.\n */\nexport const versionHistory: VersionHistoryEntry[] = [\n {\n version: '0.2.2',\n date: '2026-07-31',\n highlights: [\n '카테고리 — 글을 주제별로 분류해서 올릴 수 있어요',\n ],\n },\n {\n version: '0.2.0',\n date: '2026-07-31',\n highlights: [\n '글쓰기 / 수정 / 삭제 — 제목, 본문, 태그를 자유롭게 작성',\n '파일 첨부 — 이미지·문서를 게시글에 첨부, 이미지는 미리보기 제공',\n '댓글 — 실시간으로 댓글을 달고 지울 수 있음',\n '로그인 / 권한 — 로그인해야 글을 쓸 수 있고, 본인 글만 수정·삭제 가능(관리자는 전체 관리)',\n 'AI 자동 검열 — 욕설·부적절한 내용이 담긴 글/댓글은 AI가 자동으로 걸러 게시를 막음',\n '한 줄 설정으로 바로 시작 — pages: true 옵션 하나로 게시판 화면이 바로 만들어짐',\n ],\n note: '서버 없이 동작하는 모듈이라, 실제 보안은 Firebase 프로젝트에 규칙 파일을 등록해야 완성됩니다. README의 Security Model 안내를 꼭 따라주세요.',\n },\n {\n version: '0.1.0',\n date: '2026-07-27',\n highlights: [\n '프로젝트 최초 생성 (아직 게시판 기능은 없는 빈 Nuxt 4 프로젝트)',\n ],\n },\n]\n","import type { Timestamp } from 'firebase/firestore'\n\nexport function formatDate(ts: Timestamp | undefined): string {\n return ts?.toDate ? ts.toDate().toLocaleDateString('ko-KR') : ''\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memi-board",
3
- "version": "0.4.4",
3
+ "version": "0.4.5",
4
4
  "type": "module",
5
5
  "description": "Firebase 게시판 — core composable + thin Nuxt module(UI SFC). Nuxt 4 + Nuxt UI 호스트에 붙임",
6
6
  "license": "MIT",