memi-board 0.9.1 → 0.10.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/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../src/config.ts","../src/utils/moderation-strike.ts","../src/composables/useMemiBoardAuth.ts","../src/utils/slugify.ts","../src/utils/extractEditorImageUrls.ts","../src/utils/storagePath.ts","../src/utils/postPreview.ts","../src/composables/useMemiBoardPosts.ts","../src/composables/useMemiBoardComments.ts","../src/utils/moderation-prompt.ts","../src/composables/useMemiBoardModeration.ts","../src/utils/compressImage.ts","../src/composables/useMemiBoardStorage.ts","../src/composables/useMemiBoardSettings.ts","../src/composables/useMemiBoardUsers.ts","../src/data/versionHistory.ts","../src/utils/formatDate.ts","../src/utils/renderMarkdown.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 * 콘텐츠 검열 차단(로컬/AI flagged) 누적 횟수 이상이 되면 글·댓글 작성 제한.\n * 기본 3. 0 이하면 제한 기능 off.\n */\n blockBanThreshold?: number\n /** 경고 1회 차감 간격(ms). 기본 24시간 */\n blockBanDecayMs?: number\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 blockBanThreshold: 3,\n blockBanDecayMs: 24 * 60 * 60 * 1000,\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","/**\n * 게시판 검열 차단 누적 → 일시 이용 제한 (호스트 contentWarning 과 동일 모델).\n * - 콘텐츠 차단(로컬/AI flagged) 시 +1\n * - 유효 경고 ≥ threshold(기본 3) → 글/댓글 작성 불가\n * - 24h마다 lazy 차감 (moderationBlockAt 기준)\n */\n\nexport const DEFAULT_BLOCK_BAN_THRESHOLD = 3\nexport const DEFAULT_BLOCK_BAN_DECAY_MS = 24 * 60 * 60 * 1000\n\nexport function effectiveModerationBlockCount(\n count: number | null | undefined,\n blockAtMs: number | null | undefined,\n nowMs: number = Date.now(),\n decayMs: number = DEFAULT_BLOCK_BAN_DECAY_MS,\n): number {\n const c = Math.max(0, Math.floor(Number(count) || 0))\n if (c <= 0) return 0\n const at = typeof blockAtMs === 'number' && Number.isFinite(blockAtMs) ? blockAtMs : null\n if (at == null) return c\n if (nowMs <= at) return c\n const decayed = Math.floor((nowMs - at) / decayMs)\n return Math.max(0, c - decayed)\n}\n\n/** 제한 해제 시각(ms). 제한 없으면 null. */\nexport function moderationWriteRestrictedUntilMs(\n count: number | null | undefined,\n blockAtMs: number | null | undefined,\n nowMs: number = Date.now(),\n threshold: number = DEFAULT_BLOCK_BAN_THRESHOLD,\n decayMs: number = DEFAULT_BLOCK_BAN_DECAY_MS,\n): number | null {\n const c = Math.max(0, Math.floor(Number(count) || 0))\n if (c < threshold) return null\n const at = typeof blockAtMs === 'number' && Number.isFinite(blockAtMs) ? blockAtMs : null\n if (at == null) return nowMs\n // 유효 경고가 threshold-1 이하가 되는 시점\n const until = at + (c - (threshold - 1)) * decayMs\n return until > nowMs ? until : null\n}\n\nexport function isModerationWriteRestricted(\n count: number | null | undefined,\n blockAtMs: number | null | undefined,\n nowMs: number = Date.now(),\n threshold: number = DEFAULT_BLOCK_BAN_THRESHOLD,\n decayMs: number = DEFAULT_BLOCK_BAN_DECAY_MS,\n): boolean {\n return moderationWriteRestrictedUntilMs(count, blockAtMs, nowMs, threshold, decayMs) != null\n}\n\nexport function formatRestrictedUntilLabel(untilMs: number, nowMs: number = Date.now()): string {\n const left = Math.max(0, untilMs - nowMs)\n const hours = Math.ceil(left / (60 * 60 * 1000))\n if (hours <= 0) return '곧 해제'\n if (hours < 24) return `약 ${hours}시간 후 해제`\n const days = Math.floor(hours / 24)\n const remH = hours % 24\n if (remH === 0) return `약 ${days}일 후 해제`\n return `약 ${days}일 ${remH}시간 후 해제`\n}\n\nexport function toBlockAtMs(value: unknown): number | null {\n if (value == null) return null\n if (typeof value === 'number' && Number.isFinite(value)) return value\n if (typeof value === 'object' && value !== null && 'toMillis' in value\n && typeof (value as { toMillis: () => number }).toMillis === 'function') {\n return (value as { toMillis: () => number }).toMillis()\n }\n if (typeof value === 'object' && value !== null && 'seconds' in value) {\n const s = Number((value as { seconds: number }).seconds)\n if (Number.isFinite(s)) return s * 1000\n }\n return null\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, increment, 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, BoardUserRole } from '../types'\nimport {\n DEFAULT_BLOCK_BAN_DECAY_MS,\n DEFAULT_BLOCK_BAN_THRESHOLD,\n effectiveModerationBlockCount,\n formatRestrictedUntilLabel,\n isModerationWriteRestricted,\n moderationWriteRestrictedUntilMs,\n toBlockAtMs,\n} from '../utils/moderation-strike'\n\nexport interface UseMemiBoardAuthReturn {\n user: ReturnType<typeof useCurrentUser>\n isSignedIn: ComputedRef<boolean>\n isAdmin: ComputedRef<boolean>\n isStaff: ComputedRef<boolean>\n canManageContent: ComputedRef<boolean>\n rolePending: Ref<boolean>\n /** 유효 검열 차단 누적 (lazy decay 반영) */\n moderationBlockCount: ComputedRef<number>\n /** 글·댓글 작성 제한 중 */\n isWriteRestricted: ComputedRef<boolean>\n restrictedUntilLabel: ComputedRef<string | null>\n restrictedMessage: ComputedRef<string | null>\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 /** 콘텐츠 검열 차단 1회 기록 후 메시지 조각 반환 */\n recordContentModerationBlock: () => Promise<{\n effectiveCount: number\n restricted: boolean\n messageSuffix: string\n }>\n refreshBoardUser: () => Promise<void>\n}\n\nconst AUTH_STATE_KEY = '__MEMI_BOARD_AUTH_STATE__' as const\n\ntype BoardAuthShared = {\n role: Ref<BoardUserRole>\n rolePending: Ref<boolean>\n rawBlockCount: Ref<number>\n blockAtMs: Ref<number | null>\n watchStarted: boolean\n}\n\nfunction getSharedAuthState(): BoardAuthShared {\n const g = globalThis as typeof globalThis & { [AUTH_STATE_KEY]?: BoardAuthShared }\n if (!g[AUTH_STATE_KEY]) {\n g[AUTH_STATE_KEY] = {\n role: ref<BoardUserRole>('user'),\n rolePending: ref(true),\n rawBlockCount: ref(0),\n blockAtMs: ref<number | null>(null),\n watchStarted: false,\n }\n }\n return g[AUTH_STATE_KEY]\n}\n\n/**\n * 인증 상태 + 게시판 역할(role) + 검열 차단 누적.\n * role·경고 카운트는 globalThis 공유 (여러 composable 호출이 같은 상태).\n */\nexport function useMemiBoardAuth(): UseMemiBoardAuthReturn {\n const config = useMemiBoardConfig()\n const shared = getSharedAuthState()\n const db = useFirestore()\n const auth = useFirebaseAuth()\n const user = useCurrentUser()\n\n const { role, rolePending, rawBlockCount, blockAtMs } = shared\n\n function banThreshold(): number {\n const t = config.moderation?.blockBanThreshold\n if (t === 0) return 0\n return typeof t === 'number' && t > 0 ? Math.floor(t) : DEFAULT_BLOCK_BAN_THRESHOLD\n }\n\n function banDecayMs(): number {\n const d = config.moderation?.blockBanDecayMs\n return typeof d === 'number' && d > 0 ? d : DEFAULT_BLOCK_BAN_DECAY_MS\n }\n\n function roleDocRef(uid: string) {\n return doc(db, `${config.collectionPrefix}Users`, uid)\n }\n\n function applyUserData(data: Record<string, unknown> | undefined) {\n if (!data) {\n rawBlockCount.value = 0\n blockAtMs.value = null\n return\n }\n rawBlockCount.value = Math.max(0, Math.floor(Number(data.moderationBlockCount) || 0))\n blockAtMs.value = toBlockAtMs(data.moderationBlockAt)\n }\n\n async function ensureUserDoc(firebaseUser: User): Promise<BoardUserRole> {\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 email: firebaseUser.email,\n photoURL: firebaseUser.photoURL,\n updatedAt: serverTimestamp(),\n })\n applyUserData({ moderationBlockCount: 0 })\n return 'user'\n }\n const data = snap.data() as Record<string, unknown>\n applyUserData(data)\n await updateDoc(ref, {\n displayName: firebaseUser.displayName,\n email: firebaseUser.email,\n photoURL: firebaseUser.photoURL,\n updatedAt: serverTimestamp(),\n })\n return (['admin', 'staff', 'user'].includes(String(data.role)) ? data.role : 'user') as BoardUserRole\n }\n\n async function refreshBoardUser() {\n const uid = user.value?.uid\n if (!uid) {\n applyUserData(undefined)\n return\n }\n const snap = await getDoc(roleDocRef(uid))\n applyUserData(snap.exists() ? snap.data() as Record<string, unknown> : undefined)\n }\n\n if (!shared.watchStarted) {\n shared.watchStarted = true\n watch(\n user,\n async (current) => {\n rolePending.value = true\n if (!current) {\n role.value = 'user'\n applyUserData(undefined)\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\n const isSignedIn = computed(() => !!user.value)\n const isAdmin = computed(() => role.value === 'admin')\n const isStaff = computed(() => role.value === 'staff')\n const canManageContent = computed(() => isAdmin.value || isStaff.value)\n\n const moderationBlockCount = computed(() =>\n effectiveModerationBlockCount(\n rawBlockCount.value,\n blockAtMs.value,\n Date.now(),\n banDecayMs(),\n ),\n )\n\n const isWriteRestricted = computed(() => {\n const th = banThreshold()\n if (th <= 0) return false\n return isModerationWriteRestricted(\n rawBlockCount.value,\n blockAtMs.value,\n Date.now(),\n th,\n banDecayMs(),\n )\n })\n\n const restrictedUntilLabel = computed(() => {\n const th = banThreshold()\n if (th <= 0) return null\n const until = moderationWriteRestrictedUntilMs(\n rawBlockCount.value,\n blockAtMs.value,\n Date.now(),\n th,\n banDecayMs(),\n )\n return until != null ? formatRestrictedUntilLabel(until) : null\n })\n\n const restrictedMessage = computed(() => {\n if (!isWriteRestricted.value) return null\n const until = restrictedUntilLabel.value\n return until\n ? `콘텐츠 경고가 누적되어 글·댓글 작성이 잠시 제한됐어요. (${until})`\n : '콘텐츠 경고가 누적되어 글·댓글 작성이 잠시 제한됐어요.'\n })\n\n async function recordContentModerationBlock() {\n const uid = user.value?.uid\n const threshold = banThreshold()\n if (!uid || threshold <= 0) {\n return { effectiveCount: 0, restricted: false, messageSuffix: '' }\n }\n\n const ref = roleDocRef(uid)\n const snap = await getDoc(ref)\n if (!snap.exists()) {\n await setDoc(ref, {\n role: 'user',\n displayName: user.value?.displayName ?? null,\n moderationBlockCount: 1,\n moderationBlockAt: serverTimestamp(),\n updatedAt: serverTimestamp(),\n })\n }\n else {\n await updateDoc(ref, {\n moderationBlockCount: increment(1),\n moderationBlockAt: serverTimestamp(),\n updatedAt: serverTimestamp(),\n })\n }\n\n // increment 직후 getDoc 이 로컬 캐시로 바로 반영됨\n const nextCount = (snap.exists()\n ? Math.max(0, Math.floor(Number((snap.data() as Record<string, unknown>).moderationBlockCount) || 0))\n : 0) + 1\n const nextAt = Date.now()\n rawBlockCount.value = nextCount\n blockAtMs.value = nextAt\n\n const effectiveCount = effectiveModerationBlockCount(\n nextCount,\n nextAt,\n nextAt,\n banDecayMs(),\n )\n const restricted = isModerationWriteRestricted(\n nextCount,\n nextAt,\n nextAt,\n threshold,\n banDecayMs(),\n )\n // 앞 사유 문장과 이어 붙일 접미 (공백·구분자 포함)\n let messageSuffix = ` (경고 ${effectiveCount}/${threshold})`\n if (restricted) {\n const until = moderationWriteRestrictedUntilMs(\n nextCount,\n nextAt,\n nextAt,\n threshold,\n banDecayMs(),\n )\n const untilLabel = until != null ? formatRestrictedUntilLabel(until, nextAt) : null\n messageSuffix = untilLabel\n ? ` 경고 ${effectiveCount}회가 되어 글·댓글 작성이 제한됩니다 (${untilLabel}).`\n : ` 경고 ${effectiveCount}회가 되어 글·댓글 작성이 제한됩니다.`\n }\n return { effectiveCount, restricted, messageSuffix }\n }\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 || canManageContent.value)\n }\n\n function canDeleteComment(comment: Pick<CommentModel, 'authorUid'>) {\n return isSignedIn.value && (user.value?.uid === comment.authorUid || canManageContent.value)\n }\n\n return {\n user,\n isSignedIn,\n isAdmin,\n isStaff,\n canManageContent,\n rolePending,\n moderationBlockCount,\n isWriteRestricted,\n restrictedUntilLabel,\n restrictedMessage,\n signInWithGoogle,\n signInWithApple,\n signInWithEmail,\n signUpWithEmail,\n signOut,\n canEdit,\n canDelete: canEdit,\n canDeleteComment,\n recordContentModerationBlock,\n refreshBoardUser,\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","/**\n * 에디터 본문(markdown 또는 html)에서 이미지 URL 추출.\n * 고아 파일 정리 스케줄러·저장 시 참조 비교에 사용.\n */\nexport function extractEditorImageUrls(content: string): string[] {\n if (!content) return []\n const urls = new Set<string>()\n\n for (const m of content.matchAll(/!\\[[^\\]]*]\\((https?:\\/\\/[^)\\s]+)\\)/g)) {\n if (m[1]) urls.add(m[1].replaceAll('&amp;', '&'))\n }\n for (const m of content.matchAll(/<img[^>]*\\ssrc=[\"'](https?:\\/\\/[^\"']+)[\"']/gi)) {\n if (m[1]) urls.add(m[1].replaceAll('&amp;', '&'))\n }\n\n return [...urls]\n}\n","/**\n * Firebase Storage download URL → object path.\n * 예: .../o/board%2Fposts%2Fid%2Fimages%2Fx.png?alt=media → board/posts/id/images/x.png\n */\nexport function storagePathFromDownloadUrl(url: string): string | null {\n try {\n const m = url.match(/\\/o\\/([^?]+)/)\n if (!m?.[1]) return null\n return decodeURIComponent(m[1])\n }\n catch {\n return null\n }\n}\n\n/** path 에서 posts/{ns}/... 의 ns 추출 */\nexport function postNamespaceFromStoragePath(path: string): string | null {\n const m = path.match(/(?:^|\\/)posts\\/([^/]+)\\//)\n return m?.[1] ?? null\n}\n","import type { Attachment } from '../types'\n\nfunction decodeHtmlAttribute(value: string): string {\n return value.replace(/&amp;/g, '&').replace(/&quot;/g, '\"').replace(/&#39;/g, \"'\")\n}\n\nfunction youtubeId(value: string): string | undefined {\n try {\n const url = new URL(decodeHtmlAttribute(value))\n const host = url.hostname.toLowerCase().replace(/^www\\./, '')\n if (host === 'youtu.be') return url.pathname.split('/').filter(Boolean)[0]\n if (host.endsWith('youtube.com') || host === 'youtube-nocookie.com') {\n if (url.pathname.startsWith('/embed/')) return url.pathname.split('/')[2]\n if (url.pathname.startsWith('/shorts/')) return url.pathname.split('/')[2]\n return url.searchParams.get('v') || undefined\n }\n }\n catch {\n return undefined\n }\n}\n\nexport function buildPostPreview(content: string, attachments: Attachment[] = []): {\n summary: string\n previewImage?: string\n videoUrl?: string\n} {\n const imageMatch = content.match(/<img\\b[^>]*\\bsrc=[\"']([^\"']+)[\"']/i)\n const iframeMatch = content.match(/<iframe\\b[^>]*\\bsrc=[\"']([^\"']+)[\"']/i)\n const attachmentImage = attachments.find(item => item.type.startsWith('image/'))?.url\n const attachmentVideo = attachments.find(item => item.type.startsWith('video/'))?.url\n const videoUrl = iframeMatch ? decodeHtmlAttribute(iframeMatch[1]!) : attachmentVideo\n const videoId = videoUrl ? youtubeId(videoUrl) : undefined\n const previewImage = imageMatch\n ? decodeHtmlAttribute(imageMatch[1]!)\n : attachmentImage || (videoId ? `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg` : undefined)\n const summary = content\n .replace(/<style[\\s\\S]*?<\\/style>/gi, ' ')\n .replace(/<script[\\s\\S]*?<\\/script>/gi, ' ')\n .replace(/<[^>]+>/g, ' ')\n .replace(/&nbsp;/g, ' ')\n .replace(/&amp;/g, '&')\n .replace(/\\s+/g, ' ')\n .trim()\n .slice(0, 160)\n\n return { summary, ...(previewImage ? { previewImage } : {}), ...(videoUrl ? { videoUrl } : {}) }\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 limitToLast,\n startAfter,\n endBefore,\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 { extractEditorImageUrls } from '../utils/extractEditorImageUrls'\nimport { postNamespaceFromStoragePath, storagePathFromDownloadUrl } from '../utils/storagePath'\nimport { useMemiBoardConfig } from '../config'\nimport { buildPostPreview } from '../utils/postPreview'\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\nasync function deleteStoragePaths(storage: ReturnType<typeof getStorage>, paths: Iterable<string>): Promise<void> {\n const unique = [...new Set([...paths].filter(Boolean))]\n await Promise.all(\n unique.map(path => deleteObject(storageRef(storage, path)).catch(() => {})),\n )\n}\n\nexport interface CreatePostInput {\n /** 작성 화면 진입 시 미리 만든 Firestore 자동 ID. Storage namespace와 동일하게 사용한다. */\n postId?: string\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 /** 문서를 쓰지 않고 Firestore 자동 ID만 미리 만든다. */\n function createPostId(): string {\n return doc(postsCol()).id\n }\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 /** 공개 URL의 category + slug로 내부 Firestore 문서를 찾는다. */\n async function getPostBySlug(category: string, slug: string): Promise<PostDetail | null> {\n const snapshot = await getDocs(query(\n postsCol(),\n where('category', '==', category),\n where('slug', '==', slug),\n fbLimit(1),\n ))\n const meta = snapshot.docs[0]\n return meta ? getPost(meta.id) : null\n }\n\n /** 현재 카테고리의 최신순 목록을 기준으로 인접한 이전(오래된)·다음(최신) 글을 조회한다. */\n async function getAdjacentPosts(current: PostModel): Promise<{\n previous: PostModel | null\n next: PostModel | null\n }> {\n if (!current.category || !current.createdAt) return { previous: null, next: null }\n const base: QueryConstraint[] = [\n where('category', '==', current.category),\n orderBy('createdAt', 'desc'),\n ]\n const [previousSnapshot, nextSnapshot] = await Promise.all([\n getDocs(query(postsCol(), ...base, startAfter(current.createdAt), fbLimit(1))),\n getDocs(query(postsCol(), ...base, endBefore(current.createdAt), limitToLast(1))),\n ])\n const mapPost = (snapshot: typeof previousSnapshot): PostModel | null => {\n const item = snapshot.docs[0]\n return item ? ({ id: item.id, ...item.data() } as PostModel) : null\n }\n return {\n previous: mapPost(previousSnapshot),\n next: mapPost(nextSnapshot),\n }\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 getDocs(query(\n postsCol(),\n where('category', '==', input.category || ''),\n where('slug', '==', slug),\n fbLimit(1),\n ))).empty) {\n counter++\n slug = `${baseSlug}-${counter}`\n }\n const id = input.postId || createPostId()\n\n const preview = buildPostPreview(input.content, input.attachments)\n await setDoc(postDoc(id), {\n slug,\n title: input.title,\n ...preview,\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 // body 규칙이 부모 post 소유자를 조회하므로 부모 문서 생성 후 저장한다.\n await setDoc(bodyDoc(id), { content: input.content })\n\n return slug\n }\n\n async function updatePost(id: string, input: UpdatePostInput): Promise<void> {\n // 메타·본문을 같이 갱신. category 미선택 시 필드 제거(부분 갱신으로 예전 값이 남는 것 방지).\n const preview = buildPostPreview(input.content, input.attachments)\n await Promise.all([\n updateDoc(postDoc(id), {\n title: input.title,\n ...preview,\n previewImage: preview.previewImage ? preview.previewImage : deleteField(),\n videoUrl: preview.videoUrl ? preview.videoUrl : deleteField(),\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 /**\n * 글 삭제 순서 (규칙: Storage 삭제는 부모 post 문서가 있을 때 소유권 검사 가능)\n * 1) 메타·본문 읽어 첨부 path / 본문 이미지 URL 수집\n * 2) 댓글 전부\n * 3) 본문 body\n * 4) Storage: posts/{id}/** (images + attachments)\n * 5) Storage: 게시글 자동 ID namespace의 첨부·본문 이미지 경로\n * 6) 메타 post 문서\n */\n async function deletePost(id: string): Promise<void> {\n const [metaSnap, bodySnap] = await Promise.all([\n getDoc(postDoc(id)),\n getDoc(bodyDoc(id)),\n ])\n if (!metaSnap.exists()) return\n\n const meta = metaSnap.data() as PostModel\n const bodyContent = bodySnap.exists()\n ? String((bodySnap.data() as { content?: string }).content ?? '')\n : ''\n\n // ── 댓글 ──────────────────────────────────────────\n const commentsSnap = await getDocs(commentsCol(id))\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 // ── Storage 정리용 path 수집 (post 문서 삭제 전) ──\n const storage = getStorage(app)\n const extraPaths = new Set<string>()\n const extraNamespaces = new Set<string>()\n\n for (const att of meta.attachments ?? []) {\n if (att?.path) {\n extraPaths.add(att.path)\n const ns = postNamespaceFromStoragePath(att.path)\n if (ns && ns !== id) extraNamespaces.add(ns)\n }\n }\n\n for (const url of extractEditorImageUrls(bodyContent)) {\n const path = storagePathFromDownloadUrl(url)\n if (!path) continue\n extraPaths.add(path)\n // 썸네일 쌍 (images/foo.png → images/thumbnails/foo.jpg 추정)\n const thumbGuess = path\n .replace(/\\/images\\/([^/]+)$/, '/images/thumbnails/$1')\n .replace(/\\.[^.]+$/, '.jpg')\n if (thumbGuess !== path && thumbGuess.includes('/images/thumbnails/')) {\n extraPaths.add(thumbGuess)\n }\n // 원본이 thumbnails 가 아니고 basename 이 다른 확장자인 경우 동일 basename .jpg\n const m = path.match(/^(.*\\/images\\/)([^/]+)\\.[^.]+$/)\n if (m && !path.includes('/thumbnails/')) {\n extraPaths.add(`${m[1]}thumbnails/${m[2]}.jpg`)\n }\n const ns = postNamespaceFromStoragePath(path)\n if (ns && ns !== id) extraNamespaces.add(ns)\n }\n\n // ── 본문 (규칙: body write 는 부모 post 소유권) ──\n await deleteDoc(bodyDoc(id))\n\n // ── Storage: 최종 id 폴더 전체 (images/**, attachments/**) ──\n await deleteStorageFolder(storageRef(storage, `${prefix()}/posts/${id}`))\n\n // ── 임시 네임스페이스 폴더 (작성 중 new-ts 에 올린 파일) ──\n for (const ns of extraNamespaces) {\n await deleteStorageFolder(storageRef(storage, `${prefix()}/posts/${ns}`))\n }\n\n // ── 폴더 삭제에 안 잡힌 개별 path (다른 prefix 등) ──\n await deleteStoragePaths(storage, extraPaths)\n\n // ── 메타 마지막 ──\n await deleteDoc(postDoc(id))\n }\n\n return {\n createPostId,\n getPosts,\n getPost,\n getPostBySlug,\n getAdjacentPosts,\n createPost,\n updatePost,\n deletePost,\n }\n}\n","import { computed, onScopeDispose, ref, watch } from 'vue'\nimport type { Ref } from 'vue'\nimport type { DocumentData, QueryDocumentSnapshot, Unsubscribe } from 'firebase/firestore'\nimport { useFirestore } from 'vuefire'\nimport {\n collection,\n doc,\n documentId,\n getDocs,\n increment,\n limit,\n onSnapshot,\n orderBy,\n query,\n serverTimestamp,\n startAfter,\n where,\n writeBatch,\n} 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\nexport interface AddReplyInput extends AddCommentInput {\n parent: CommentModel\n}\n\nconst COMMENT_PAGE_SIZE = 10\nconst REPLY_PAGE_SIZE = 5\n\n/** 10개씩 순차 조회한 뒤 마지막 페이지에서만 최신 1개를 실시간 구독한다. */\nexport function useMemiBoardComments(\n postId: string | Ref<string>,\n options: { subscribe?: boolean } = {},\n) {\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 // limit(1) 리스너는 최신 댓글 알림 용도다. 쿼리 결과 교체로 기존 댓글이\n // 사라지지 않도록 수신 문서를 별도 배열에 누적한다.\n const liveComments = ref<CommentModel[]>([])\n // parentId 도입 전 댓글은 실시간 10개 슬롯과 분리해 최초 1회만 읽는다.\n const legacyComments = ref<CommentModel[]>([])\n const legacyPending = ref(options.subscribe !== false)\n const pagedComments = ref<CommentModel[]>([])\n const loadingMore = ref(false)\n const hasMore = ref(options.subscribe !== false)\n const initialPending = ref(options.subscribe !== false)\n let pageCursor: QueryDocumentSnapshot<DocumentData> | null = null\n let liveEnableTimer: ReturnType<typeof setTimeout> | undefined\n let stopLiveSubscription: Unsubscribe | undefined\n\n function createdAtMs(comment: CommentModel): number {\n return comment.createdAt?.toMillis?.() ?? 0\n }\n\n const comments = computed(() => {\n const byId = new Map<string, CommentModel>()\n for (const comment of [...liveComments.value, ...legacyComments.value, ...pagedComments.value]) {\n if (comment.id) byId.set(comment.id, comment)\n }\n return [...byId.values()].sort((a, b) =>\n createdAtMs(a) - createdAtMs(b) || String(a.id).localeCompare(String(b.id)),\n )\n })\n\n watch(id, async (currentId) => {\n pagedComments.value = []\n liveComments.value = []\n legacyComments.value = []\n pageCursor = null\n clearTimeout(liveEnableTimer)\n liveEnableTimer = undefined\n stopLiveSubscription?.()\n stopLiveSubscription = undefined\n hasMore.value = options.subscribe !== false\n if (options.subscribe === false) return\n\n legacyPending.value = true\n initialPending.value = true\n try {\n const snapshotPromise = getDocs(query(\n collection(db, `${prefix()}Posts`, currentId, 'comments'),\n orderBy('createdAt', 'desc'),\n orderBy(documentId(), 'desc'),\n ))\n await Promise.all([snapshotPromise.then((snapshot) => {\n if (id.value !== currentId) return\n const allComments = snapshot.docs\n .map(item => ({ id: item.id, ...item.data() } as CommentModel))\n legacyComments.value = allComments.filter(comment => comment.parentId === undefined)\n }), loadMore()])\n if (id.value !== currentId) return\n }\n finally {\n if (id.value === currentId) {\n legacyPending.value = false\n initialPending.value = false\n }\n }\n }, { immediate: true })\n\n function startLiveSubscription() {\n stopLiveSubscription?.()\n stopLiveSubscription = onSnapshot(query(\n collection(db, `${prefix()}Posts`, id.value, 'comments'),\n where('parentId', '==', null),\n orderBy('createdAt', 'desc'),\n orderBy(documentId(), 'desc'),\n limit(1),\n ), (snapshot) => {\n const byId = new Map(liveComments.value.map(comment => [comment.id, comment]))\n for (const change of snapshot.docChanges()) {\n if (change.type === 'removed') continue\n byId.set(change.doc.id, { id: change.doc.id, ...change.doc.data() } as CommentModel)\n }\n liveComments.value = [...byId.values()]\n }, (cause) => {\n console.error('[memi-board:comments] onSnapshot failed', cause)\n })\n }\n\n async function loadMore(): Promise<void> {\n if (loadingMore.value || !hasMore.value) return\n loadingMore.value = true\n try {\n const constraints = [\n where('parentId', '==', null),\n orderBy('createdAt', 'asc'),\n orderBy(documentId(), 'asc'),\n ...(pageCursor ? [startAfter(pageCursor)] : []),\n limit(COMMENT_PAGE_SIZE + 1),\n ]\n const snapshot = await getDocs(query(\n collection(db, `${prefix()}Posts`, id.value, 'comments'),\n ...constraints,\n ))\n const pageDocs = snapshot.docs.slice(0, COMMENT_PAGE_SIZE)\n const page = pageDocs.map(item => ({ id: item.id, ...item.data() } as CommentModel))\n const existing = new Set(pagedComments.value.map(comment => comment.id))\n pagedComments.value.push(...page.filter(comment => !existing.has(comment.id)))\n pageCursor = pageDocs.at(-1) ?? pageCursor\n if (snapshot.size <= COMMENT_PAGE_SIZE) {\n hasMore.value = false\n liveEnableTimer = setTimeout(() => {\n liveEnableTimer = undefined\n startLiveSubscription()\n }, 1_000)\n }\n }\n finally {\n loadingMore.value = false\n }\n }\n\n onScopeDispose(() => {\n clearTimeout(liveEnableTimer)\n stopLiveSubscription?.()\n })\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 parentId: null,\n rootId: commentRef.id,\n depth: 0,\n replyToUid: null,\n replyToName: null,\n replyCount: 0,\n isReply: false,\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 addReply(input: AddReplyInput): Promise<void> {\n if (!input.parent.id) throw new Error('답글 대상 댓글을 찾을 수 없습니다.')\n const batch = writeBatch(db)\n const p = prefix()\n const commentsCol = collection(db, `${p}Posts`, id.value, 'comments')\n const replyRef = doc(commentsCol)\n const rootId = input.parent.parentId == null\n ? input.parent.id\n : input.parent.rootId\n if (!rootId) throw new Error('댓글 스레드를 찾을 수 없습니다.')\n const depth = Math.min((input.parent.depth ?? 0) + 1, 2)\n\n batch.set(replyRef, {\n postId: id.value,\n body: input.body,\n authorUid: input.authorUid,\n authorName: input.authorName,\n authorPhoto: input.authorPhoto,\n moderationStatus: 'approved',\n parentId: input.parent.id,\n rootId,\n depth,\n replyToUid: input.parent.authorUid,\n replyToName: input.parent.authorName,\n replyCount: 0,\n isReply: true,\n createdAt: serverTimestamp(),\n })\n batch.update(doc(commentsCol, rootId), { replyCount: increment(1) })\n batch.update(doc(db, `${p}Posts`, id.value), { commentCount: increment(1) })\n await batch.commit()\n }\n\n async function deleteComment(comment: CommentModel): Promise<void> {\n if (!comment.id) return\n if (comment.parentId == null && (comment.replyCount ?? 0) > 0) {\n throw new Error('답글이 있는 댓글은 현재 삭제할 수 없습니다.')\n }\n const batch = writeBatch(db)\n const p = prefix()\n batch.delete(doc(db, `${p}Posts`, id.value, 'comments', comment.id))\n if (comment.parentId && comment.rootId) {\n batch.update(doc(db, `${p}Posts`, id.value, 'comments', comment.rootId), { replyCount: increment(-1) })\n }\n batch.update(doc(db, `${p}Posts`, id.value), { commentCount: increment(-1) })\n await batch.commit()\n pagedComments.value = pagedComments.value.filter(item => item.id !== comment.id)\n legacyComments.value = legacyComments.value.filter(item => item.id !== comment.id)\n liveComments.value = liveComments.value.filter(item => item.id !== comment.id)\n }\n\n return {\n comments,\n commentsPending: computed(() => initialPending.value || legacyPending.value),\n hasMore,\n loadingMore,\n loadMore,\n addComment,\n addReply,\n deleteComment,\n }\n}\n\n/** 펼친 스레드의 답글을 5개씩 일회성 조회한다. */\nexport function useMemiBoardReplies(postId: string, rootId: string) {\n const config = useMemiBoardConfig()\n const db = useFirestore()\n const replies = ref<CommentModel[]>([])\n const loading = ref(false)\n const loaded = ref(false)\n const hasMore = ref(true)\n let cursor: QueryDocumentSnapshot<DocumentData> | null = null\n let stopLiveSubscription: Unsubscribe | undefined\n\n function sortReplies(list: CommentModel[]) {\n return list.sort((a, b) =>\n (a.createdAt?.toMillis?.() ?? 0) - (b.createdAt?.toMillis?.() ?? 0)\n || String(a.id).localeCompare(String(b.id)),\n )\n }\n\n function startLiveSubscription() {\n if (stopLiveSubscription) return\n stopLiveSubscription = onSnapshot(query(\n collection(db, `${config.collectionPrefix}Posts`, postId, 'comments'),\n where('rootId', '==', rootId),\n where('isReply', '==', true),\n orderBy('createdAt', 'desc'),\n orderBy(documentId(), 'desc'),\n limit(1),\n ), (snapshot) => {\n const byId = new Map(replies.value.map(reply => [reply.id, reply]))\n for (const change of snapshot.docChanges()) {\n if (change.type === 'removed') continue\n byId.set(change.doc.id, { id: change.doc.id, ...change.doc.data() } as CommentModel)\n }\n replies.value = sortReplies([...byId.values()])\n }, (cause) => {\n console.error('[memi-board:replies] onSnapshot failed', cause)\n })\n }\n\n async function loadMore(): Promise<void> {\n if (loading.value || !hasMore.value) return\n loading.value = true\n try {\n const constraints = [\n where('rootId', '==', rootId),\n where('isReply', '==', true),\n orderBy('createdAt', 'asc'),\n orderBy(documentId(), 'asc'),\n ...(cursor ? [startAfter(cursor)] : []),\n limit(REPLY_PAGE_SIZE + 1),\n ]\n const snapshot = await getDocs(query(\n collection(db, `${config.collectionPrefix}Posts`, postId, 'comments'),\n ...constraints,\n ))\n const existing = new Set(replies.value.map(reply => reply.id))\n const pageDocs = snapshot.docs.slice(0, REPLY_PAGE_SIZE)\n const page = pageDocs\n .map(item => ({ id: item.id, ...item.data() } as CommentModel))\n .filter(reply => !existing.has(reply.id))\n replies.value = sortReplies([...replies.value, ...page])\n cursor = pageDocs.at(-1) ?? cursor\n loaded.value = true\n if (snapshot.size <= REPLY_PAGE_SIZE) {\n hasMore.value = false\n startLiveSubscription()\n }\n }\n finally {\n loading.value = false\n }\n }\n\n function reset() {\n stopLiveSubscription?.()\n stopLiveSubscription = undefined\n replies.value = []\n cursor = null\n loaded.value = false\n hasMore.value = true\n }\n\n async function refresh(): Promise<void> {\n reset()\n await loadMore()\n }\n\n onScopeDispose(() => stopLiveSubscription?.())\n\n return { replies, loading, loaded, hasMore, loadMore, refresh }\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 (예: \"욕설(비속어)이 포함되어 게시할 수 없습니다.\", \"광고·스팸성 문구가 있어 게시할 수 없습니다.\")\n 욕설 단어 전체를 그대로 길게 인용하지 말고, 유형(욕설/혐오/스팸 등)을 분명히 밝힌다.\n- flagged=false면 category는 \"none\", reason은 \"\"`\n\n/** 사용자 안내용 카테고리 라벨 */\nexport const MODERATION_CATEGORY_LABELS: Record<string, string> = {\n abuse: '욕설·비속어·모욕',\n spam: '스팸·광고',\n adult: '선정적·성인 내용',\n violence: '폭력·위협',\n other: '부적절한 내용',\n none: '',\n}\n\n/** 차단 사유를 사용자에게 읽기 쉽게 정리 */\nexport function formatModerationUserReason(opts: {\n reason?: string\n category?: string\n via?: string\n localHit?: string\n}): string {\n if (opts.via === 'local' && opts.localHit) {\n const hit = opts.localHit.trim()\n // 짧은 금칙어만 표시 (너무 길면 유형만)\n if (hit.length > 0 && hit.length <= 12) {\n return `「${hit}」 같은 표현은 사용할 수 없습니다. 다른 말로 바꿔 주세요.`\n }\n return '욕설·비속어가 포함되어 게시할 수 없습니다. 표현을 바꿔 주세요.'\n }\n\n const raw = (opts.reason || '').trim()\n if (raw) return raw\n\n const cat = opts.category && opts.category !== 'none'\n ? MODERATION_CATEGORY_LABELS[opts.category] || opts.category\n : ''\n if (cat) return `${cat}이(가) 포함되어 게시할 수 없습니다. 내용을 수정해 주세요.`\n return '게시할 수 없는 내용이 포함되어 있습니다. 내용을 수정해 주세요.'\n}\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 {\n buildLocalBlockRegex,\n DEFAULT_LOCAL_BLOCKLIST,\n formatModerationUserReason,\n MODERATION_SYSTEM,\n parseModerationJson,\n} from '../utils/moderation-prompt'\nimport { useMemiBoardConfig } from '../config'\nimport { useMemiBoardAuth } from './useMemiBoardAuth'\nimport type { ModerationResult, ModerationVia } from '../types'\n\nconst APPROVED: ModerationResult = { flagged: false, category: 'none', reason: '', via: 'empty' }\n\n/**\n * 글쓰기 전 블로킹 검열.\n * 이용 제한 확인 → 로컬 비속어 → Firebase AI Logic(Gemini).\n * 콘텐츠 차단(local/ai) 시 boardUsers 경고 누적.\n */\nexport function useMemiBoardModeration() {\n const config = useMemiBoardConfig()\n const moderation = config.moderation ?? {}\n const app = useFirebaseApp()\n const {\n isWriteRestricted,\n restrictedMessage,\n recordContentModerationBlock,\n } = useMemiBoardAuth()\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 const hit = match[0] || ''\n return {\n flagged: true,\n category: 'abuse',\n reason: formatModerationUserReason({ via: 'local', localHit: hit, category: 'abuse' }),\n via: 'local',\n }\n }\n return null\n }\n\n function getModerationModel() {\n const modelName = moderation.model ?? 'gemini-3.5-flash-lite'\n const useLimited = moderation.useLimitedUseAppCheckTokens !== false\n const ai = getAI(app, {\n backend: new GoogleAIBackend(),\n useLimitedUseAppCheckTokens: useLimited,\n })\n return 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 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 /** 사유 + 경고 횟수를 한 문장으로 */\n function withUserFacingReason(result: ModerationResult, messageSuffix = ''): ModerationResult {\n const why = formatModerationUserReason({\n reason: result.reason,\n category: result.category,\n via: result.via,\n })\n // 예: 「시발」 같은 표현은… (경고 2/3)\n const reason = messageSuffix ? `${why}${messageSuffix}` : why\n return { ...result, reason }\n }\n\n /** 콘텐츠 위반(local/ai)만 경고 누적. API 오류·이용제한 자체는 제외. */\n async function applyStrikeIfContentBlock(result: ModerationResult): Promise<ModerationResult> {\n if (!result.flagged || result.error) return result\n if (result.via !== 'local' && result.via !== 'ai') return result\n try {\n const { messageSuffix } = await recordContentModerationBlock()\n return withUserFacingReason(result, messageSuffix)\n }\n catch (e) {\n console.warn('[memi-board] record moderation strike failed', e instanceof Error ? e.message : e)\n return withUserFacingReason(result)\n }\n }\n\n async function checkText(text: string): Promise<ModerationResult> {\n if (isWriteRestricted.value) {\n return {\n flagged: true,\n category: 'other',\n reason: restrictedMessage.value\n || '콘텐츠 경고가 누적되어 글·댓글 작성이 잠시 제한됐어요.',\n via: 'restricted',\n }\n }\n\n const trimmed = text.trim()\n if (!trimmed) {\n return { ...APPROVED, via: 'empty' as ModerationVia }\n }\n\n const local = localCheck(trimmed)\n if (local) return applyStrikeIfContentBlock(local)\n\n if (moderation.enabled === false) {\n return { ...APPROVED, via: 'disabled' }\n }\n\n try {\n const model = getModerationModel()\n const result = await model.generateContent(`심사할 텍스트:\\n\"\"\"${trimmed.slice(0, 4000)}\"\"\"`)\n const raw = result.response.text()\n const parsed = parseModerationJson(raw)\n if (!parsed) return errorResult()\n const out: ModerationResult = {\n flagged: parsed.flagged,\n category: parsed.category as ModerationResult['category'],\n reason: formatModerationUserReason({\n reason: parsed.reason,\n category: parsed.category,\n via: 'ai',\n }),\n via: 'ai',\n }\n if (out.flagged) return applyStrikeIfContentBlock(out)\n return out\n }\n catch (e) {\n console.warn('[memi-board] moderation AI failed', e instanceof Error ? e.message : e)\n return errorResult()\n }\n }\n\n async function checkImage(file: File): Promise<ModerationResult> {\n if (isWriteRestricted.value) {\n return {\n flagged: true,\n category: 'other',\n reason: restrictedMessage.value\n || '콘텐츠 경고가 누적되어 글·댓글 작성이 잠시 제한됐어요.',\n via: 'restricted',\n }\n }\n\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 = getModerationModel()\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) return errorResult()\n const out: ModerationResult = {\n flagged: parsed.flagged,\n category: parsed.category as ModerationResult['category'],\n reason: formatModerationUserReason({\n reason: parsed.reason,\n category: parsed.category,\n via: 'ai',\n }),\n via: 'ai',\n }\n if (out.flagged) return applyStrikeIfContentBlock(out)\n return out\n }\n catch (e) {\n console.warn('[memi-board] moderation AI image failed', e instanceof Error ? e.message : e)\n return errorResult()\n }\n }\n\n return { checkText, checkImage }\n}\n","/**\n * Canvas API로 썸네일용 이미지 압축 (shineb 와 동일).\n */\nexport async function compressImage(\n file: File | Blob,\n { maxWidth = 400, quality = 0.8 }: { maxWidth?: number, quality?: number } = {},\n): Promise<Blob> {\n return new Promise((resolve, reject) => {\n const img = new Image()\n const objectUrl = URL.createObjectURL(file)\n\n img.onload = () => {\n URL.revokeObjectURL(objectUrl)\n\n const ratio = Math.min(1, maxWidth / img.naturalWidth)\n const canvas = document.createElement('canvas')\n canvas.width = Math.round(img.naturalWidth * ratio)\n canvas.height = Math.round(img.naturalHeight * ratio)\n\n const ctx = canvas.getContext('2d')\n if (!ctx) {\n reject(new Error('Canvas를 사용할 수 없습니다'))\n return\n }\n ctx.drawImage(img, 0, 0, canvas.width, canvas.height)\n\n canvas.toBlob(\n blob => (blob ? resolve(blob) : reject(new Error('압축 실패'))),\n 'image/jpeg',\n quality,\n )\n }\n\n img.onerror = () => {\n URL.revokeObjectURL(objectUrl)\n reject(new Error('이미지 로드 실패'))\n }\n\n img.src = objectUrl\n })\n}\n","import {\n getStorage,\n ref as storageRef,\n uploadBytes,\n uploadBytesResumable,\n getDownloadURL,\n deleteObject,\n} from 'firebase/storage'\nimport type { UploadTask } from 'firebase/storage'\nimport { useFirebaseApp } from 'vuefire'\nimport { safeFileName } from '../utils/slugify'\nimport { compressImage } from '../utils/compressImage'\nimport { useMemiBoardConfig } from '../config'\nimport type { Attachment, EditorImageEntry } from '../types'\n\n/** 에디터 이미지 최대 크기 (바이트) */\nexport const EDITOR_IMAGE_MAX_BYTES = 5 * 1024 * 1024\n\nexport function useMemiBoardStorage() {\n const config = useMemiBoardConfig()\n const app = useFirebaseApp()\n const prefix = () => config.collectionPrefix\n\n /** postId는 작성 화면 진입 시 미리 생성한 Firestore 자동 ID를 사용한다. */\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 /**\n * 에디터 본문 이미지: 원본 + 400px JPEG 썸네일 (shineb 동일).\n * Storage:\n * `{prefix}/posts/{postId}/images/{ts}-{name}.ext`\n * `{prefix}/posts/{postId}/images/thumbnails/{ts}-{name}.jpg`\n */\n async function uploadEditorImage(file: File, postId: string): Promise<EditorImageEntry> {\n if (!file.type.startsWith('image/')) {\n throw new Error('이미지 파일만 업로드할 수 있습니다.')\n }\n if (file.size > EDITOR_IMAGE_MAX_BYTES) {\n throw new Error(`이미지는 ${EDITOR_IMAGE_MAX_BYTES / 1024 / 1024}MB 이하여야 합니다.`)\n }\n\n const storage = getStorage(app)\n const ext = (file.name.split('.').pop() || file.type.split('/')[1] || 'png')\n .replace(/[^a-zA-Z0-9]/g, '')\n .slice(0, 8) || 'png'\n const safeName = safeFileName(file.name.replace(/\\.[^.]+$/, '') || 'image', 40)\n const baseName = `${Date.now()}-${safeName || 'image'}`\n const ns = postId || `new-${Date.now()}`\n\n const originalPath = `${prefix()}/posts/${ns}/images/${baseName}.${ext}`\n const originalRef = storageRef(storage, originalPath)\n await uploadBytes(originalRef, file, {\n contentType: file.type || 'image/png',\n })\n const originalUrl = await getDownloadURL(originalRef)\n\n const thumbBlob = await compressImage(file, { maxWidth: 400, quality: 0.8 })\n const thumbnailPath = `${prefix()}/posts/${ns}/images/thumbnails/${baseName}.jpg`\n const thumbRef = storageRef(storage, thumbnailPath)\n await uploadBytes(thumbRef, thumbBlob, { contentType: 'image/jpeg' })\n const thumbnailUrl = await getDownloadURL(thumbRef)\n\n return { originalUrl, originalPath, thumbnailUrl, thumbnailPath }\n }\n\n /** 원본·썸네일 쌍 삭제 (고아 정리·첨부 삭제용) */\n async function deleteEditorImage(entry: Pick<EditorImageEntry, 'originalPath' | 'thumbnailPath'>): Promise<void> {\n const storage = getStorage(app)\n await Promise.all([\n deleteObject(storageRef(storage, entry.originalPath)).catch(() => {}),\n deleteObject(storageRef(storage, entry.thumbnailPath)).catch(() => {}),\n ])\n }\n\n return {\n uploadAttachment,\n deleteAttachment,\n uploadEditorImage,\n deleteEditorImage,\n }\n}\n","import { computed } from 'vue'\nimport { useCollection, useFirestore } from 'vuefire'\nimport { collection, deleteDoc, doc, orderBy, query, serverTimestamp, setDoc, writeBatch } from 'firebase/firestore'\nimport { useMemiBoardConfig } from '../config'\nimport { useMemiBoardAuth } from './useMemiBoardAuth'\nimport { slugify } from '../utils/slugify'\nimport type { BoardCategory } from '../types'\n\n/** 필요할 때 호스트가 명시적으로 사용할 수 있는 예시 카테고리. 자동 적용하지 않는다. */\nexport const DEFAULT_CATEGORIES: BoardCategory[] = [\n { id: 'free', label: '자유', listView: 'default', writeRole: 'user', order: 0 },\n { id: 'notice', label: '공지', listView: 'default', writeRole: 'admin', order: 1 },\n { id: 'question', label: '질문', listView: 'default', writeRole: 'user', order: 2 },\n]\n\n/** 게시판 카테고리 — `{prefix}Settings/config/categories/{categoryId}` 개별 문서. */\nexport function useMemiBoardSettings() {\n const config = useMemiBoardConfig()\n const db = useFirestore()\n const { isSignedIn } = useMemiBoardAuth()\n\n const settingsRef = computed(() => doc(db, `${config.collectionPrefix}Settings`, 'config'))\n const categoriesRef = computed(() =>\n collection(db, `${config.collectionPrefix}Settings`, 'config', 'categories'),\n )\n const categoriesQuery = computed(() => query(categoriesRef.value, orderBy('order', 'asc')))\n // VueFire는 Query의 SSR state key를 경로만으로 만들 수 없으므로 명시해야\n // 서버 렌더 결과와 클라이언트 hydration이 같은 데이터를 사용한다.\n const { data: categoryDocs, pending: settingsPending } = useCollection<BoardCategory>(categoriesQuery, {\n ssrKey: `${config.collectionPrefix}Settings/config/categories`,\n })\n const categories = computed<BoardCategory[]>(() => categoryDocs.value.map((item, index) => ({\n ...item,\n id: item.id,\n order: typeof item.order === 'number' ? item.order : index,\n listView: item.listView ?? 'default',\n writeRole: item.writeRole ?? 'user',\n })))\n\n function categoryLabel(id: string | undefined): string | undefined {\n if (!id) return undefined\n return categories.value.find(category => category.id === id)?.label ?? id\n }\n\n async function saveCategory(category: BoardCategory, order = category.order ?? 0): Promise<void> {\n if (!isSignedIn.value) throw new Error('로그인이 필요합니다.')\n const id = category.id.trim()\n const label = category.label.trim()\n if (!id || !label) throw new Error('카테고리 ID와 이름을 입력해 주세요.')\n await setDoc(settingsRef.value, { updatedAt: serverTimestamp() }, { merge: true })\n await setDoc(doc(categoriesRef.value, id), {\n label,\n listView: category.listView ?? 'default',\n writeRole: category.writeRole ?? 'user',\n order,\n updatedAt: serverTimestamp(),\n }, { merge: true })\n }\n\n /** 호환용 일괄 저장. 각 카테고리는 독립 문서로 batch 저장한다. */\n async function saveCategories(next: BoardCategory[]): Promise<void> {\n if (!isSignedIn.value) throw new Error('로그인이 필요합니다.')\n const batch = writeBatch(db)\n batch.set(settingsRef.value, { updatedAt: serverTimestamp() }, { merge: true })\n next.forEach((category, order) => {\n const id = category.id.trim()\n const label = category.label.trim()\n if (!id || !label) throw new Error('카테고리 ID와 이름을 입력해 주세요.')\n batch.set(doc(categoriesRef.value, id), {\n label,\n listView: category.listView ?? 'default',\n writeRole: category.writeRole ?? 'user',\n order,\n updatedAt: serverTimestamp(),\n }, { merge: true })\n })\n await batch.commit()\n }\n\n async function deleteCategory(id: string): Promise<void> {\n if (!isSignedIn.value) throw new Error('로그인이 필요합니다.')\n await deleteDoc(doc(categoriesRef.value, id))\n }\n\n async function ensureSettings(): Promise<void> {\n return Promise.resolve()\n }\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 const existing = categories.value.find(category => category.label === trimmed)\n if (existing) return existing.id\n\n const base = slugify(trimmed) || `cat-${Date.now()}`\n let id = base\n let suffix = 2\n const used = new Set(categories.value.map(category => category.id))\n while (used.has(id)) id = `${base}-${suffix++}`\n await saveCategory({ id, label: trimmed, listView: 'default', writeRole: 'user', order: categories.value.length })\n return id\n }\n\n return {\n categories,\n categoryLabel,\n settingsPending,\n ensureSettings,\n addCategory,\n saveCategory,\n saveCategories,\n deleteCategory,\n DEFAULT_CATEGORIES,\n }\n}\n","import { collection, doc, serverTimestamp, updateDoc } from 'firebase/firestore'\nimport { useCollection, useFirestore } from 'vuefire'\nimport { useMemiBoardConfig } from '../config'\nimport type { BoardUserModel, BoardUserRole } from '../types'\n\nexport const BOARD_USER_ROLES: Array<{\n value: BoardUserRole\n label: string\n description: string\n}> = [\n { value: 'admin', label: '관리자', description: '사용자 역할, 게시판 설정과 모든 콘텐츠를 관리합니다.' },\n { value: 'staff', label: '스태프', description: '모든 게시글과 댓글을 관리하지만 사용자 역할과 게시판 설정은 바꿀 수 없습니다.' },\n { value: 'user', label: '사용자', description: '글과 댓글을 작성하고 본인이 작성한 콘텐츠만 수정·삭제할 수 있습니다.' },\n]\n\nexport function useMemiBoardUsers() {\n const config = useMemiBoardConfig()\n const db = useFirestore()\n const collectionName = `${config.collectionPrefix}Users`\n const users = useCollection<BoardUserModel>(collection(db, collectionName), {\n ssrKey: `${collectionName}-management`,\n })\n\n async function updateUserRole(uid: string, role: BoardUserRole) {\n if (!BOARD_USER_ROLES.some(item => item.value === role)) throw new Error('지원하지 않는 역할입니다.')\n await updateDoc(doc(db, collectionName, uid), { role, updatedAt: serverTimestamp() })\n }\n\n return { users, usersPending: users.pending, updateUserRole }\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.9.1',\n date: '2026-08-04',\n highlights: ['npm으로 설치한 프로젝트에서 사용자 관리 화면이 정상적으로 빌드돼요'],\n },\n {\n version: '0.9.0',\n date: '2026-08-04',\n highlights: [\n '댓글에 답글을 달고 대화 흐름을 최대 2단계로 볼 수 있어요',\n '댓글 10개·답글 5개 단위로 이어서 보고 마지막부터 새 댓글을 실시간으로 받아요',\n '더보기 버튼이 화면에 들어오면 로딩 표시와 함께 다음 댓글을 자동으로 불러와요',\n ],\n },\n {\n version: '0.8.0',\n date: '2026-08-04',\n highlights: [\n '최근 댓글 5개는 실시간으로 보고 이전 댓글은 5개씩 불러올 수 있어요',\n '댓글 시간이 상대시간으로 표시되고 전체 시간은 툴팁으로 확인할 수 있어요',\n '댓글 작성 영역을 대화 흐름의 맨 아래로 옮겼어요',\n ],\n },\n {\n version: '0.7.0',\n date: '2026-08-04',\n highlights: [\n '카테고리마다 글쓰기 권한을 일반 이상·스태프 이상·관리자만으로 설정할 수 있어요',\n '게시판 설정 화면의 SSR hydration 경고를 해결했어요',\n ],\n },\n {\n version: '0.6.0',\n date: '2026-08-04',\n highlights: [\n '관리자가 게시판 사용자를 확인하고 관리자·스태프·사용자 역할을 설정할 수 있어요',\n '스태프는 게시판 설정이나 사용자 역할은 바꾸지 않고 게시글과 댓글을 관리할 수 있어요',\n ],\n },\n {\n version: '0.5.0',\n date: '2026-08-04',\n highlights: [\n '게시판마다 일반·이미지·영상 목록 화면을 선택할 수 있어요',\n 'YouTube 링크를 붙여넣으면 본문에 영상으로 표시돼요',\n '게시글 아래에서 이전 글·목록·다음 글로 이동할 수 있어요',\n '관리자가 카테고리를 각각 저장하고 관리할 수 있어요',\n ],\n },\n {\n version: '0.4.15',\n date: '2026-08-03',\n highlights: ['설정 화면이 설치된 프로젝트에서 정상적으로 열리도록 고쳤어요'],\n },\n {\n version: '0.4.14',\n date: '2026-08-03',\n highlights: [\n '관리자가 카테고리 이름과 순서를 한 화면에서 관리할 수 있어요',\n '카테고리마다 일반·이미지·영상 목록 방식을 선택할 수 있어요',\n ],\n },\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","/**\n * 게시글 상세용 가벼운 마크다운 → HTML (TipTap 없이).\n * 목록 이동 시 UEditor destroy/recreate 로 나는\n * \"Adding different instances of a keyed plugin\" 를 피한다.\n * 신뢰된 작성자 콘텐츠 전제 — 기본 이스케이프 후 허용 패턴만 복원.\n */\n\nfunction escapeHtml(s: string): string {\n return s\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n}\n\n/** 인라인 마크다운 (이미 escape 된 문자열에 적용) */\nfunction formatInline(s: string): string {\n let out = s\n // code\n out = out.replace(/`([^`]+)`/g, '<code>$1</code>')\n // images ![alt](url) — url 은 http(s) 만\n out = out.replace(\n /!\\[([^\\]]*)\\]\\((https?:\\/\\/[^)\\s]+)\\)/g,\n '<img src=\"$2\" alt=\"$1\" class=\"max-w-full h-auto rounded-lg my-3\" loading=\"lazy\" />',\n )\n // links [text](url)\n out = out.replace(\n /\\[([^\\]]+)\\]\\((https?:\\/\\/[^)\\s]+)\\)/g,\n '<a href=\"$2\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-primary underline\">$1</a>',\n )\n // bold ** ** 먼저\n out = out.replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>')\n // italic * * (bold 처리 후 남은 단일 *)\n out = out.replace(/\\*([^*]+)\\*/g, '<em>$1</em>')\n // strikethrough ~~\n out = out.replace(/~~([^~]+)~~/g, '<del>$1</del>')\n return out\n}\n\n/**\n * GFM-ish 최소 변환. 코드 펜스·목록·제목·인용·hr·단락.\n */\nexport function renderMarkdownToHtml(markdown: string): string {\n if (!markdown?.trim()) return ''\n\n const lines = markdown.replace(/\\r\\n/g, '\\n').split('\\n')\n const blocks: string[] = []\n let i = 0\n let inCode = false\n let codeLang = ''\n let codeBuf: string[] = []\n let listType: 'ul' | 'ol' | null = null\n let listItems: string[] = []\n\n function flushList() {\n if (!listType || !listItems.length) {\n listType = null\n listItems = []\n return\n }\n const tag = listType\n blocks.push(`<${tag}>${listItems.map(li => `<li>${formatInline(li)}</li>`).join('')}</${tag}>`)\n listType = null\n listItems = []\n }\n\n while (i < lines.length) {\n const line = lines[i] ?? ''\n\n // fenced code\n const fence = line.match(/^```(\\w*)\\s*$/)\n if (fence) {\n if (inCode) {\n blocks.push(\n `<pre class=\"overflow-x-auto rounded-lg bg-muted p-3 text-sm\"><code>${codeBuf.join('\\n')}</code></pre>`,\n )\n codeBuf = []\n inCode = false\n codeLang = ''\n }\n else {\n flushList()\n inCode = true\n codeLang = fence[1] || ''\n void codeLang\n }\n i++\n continue\n }\n if (inCode) {\n codeBuf.push(escapeHtml(line))\n i++\n continue\n }\n\n // hr\n if (/^(-{3,}|\\*{3,}|_{3,})\\s*$/.test(line)) {\n flushList()\n blocks.push('<hr class=\"my-4 border-default\" />')\n i++\n continue\n }\n\n // heading\n const h = line.match(/^(#{1,3})\\s+(.+)$/)\n if (h) {\n flushList()\n const level = h[1]!.length\n blocks.push(`<h${level} class=\"font-semibold mt-4 mb-2\">${formatInline(escapeHtml(h[2]!))}</h${level}>`)\n i++\n continue\n }\n\n // blockquote\n if (line.startsWith('> ')) {\n flushList()\n const quoteLines: string[] = []\n while (i < lines.length && (lines[i] ?? '').startsWith('> ')) {\n quoteLines.push(escapeHtml((lines[i] ?? '').slice(2)))\n i++\n }\n blocks.push(\n `<blockquote class=\"border-l-4 border-default pl-3 text-muted my-3\">${formatInline(quoteLines.join('<br />'))}</blockquote>`,\n )\n continue\n }\n\n // unordered list\n const ul = line.match(/^[-*+]\\s+(.+)$/)\n if (ul) {\n if (listType && listType !== 'ul') flushList()\n listType = 'ul'\n listItems.push(escapeHtml(ul[1]!))\n i++\n continue\n }\n\n // ordered list\n const ol = line.match(/^\\d+\\.\\s+(.+)$/)\n if (ol) {\n if (listType && listType !== 'ol') flushList()\n listType = 'ol'\n listItems.push(escapeHtml(ol[1]!))\n i++\n continue\n }\n\n // empty line\n if (!line.trim()) {\n flushList()\n i++\n continue\n }\n\n // paragraph (merge consecutive non-empty)\n flushList()\n const para: string[] = []\n while (i < lines.length) {\n const l = lines[i] ?? ''\n if (!l.trim()) break\n if (/^(#{1,3}\\s|```|[-*+]\\s|\\d+\\.\\s|> |(-{3,}|\\*{3,})\\s*$)/.test(l)) break\n para.push(escapeHtml(l))\n i++\n }\n blocks.push(`<p class=\"my-2 leading-relaxed\">${formatInline(para.join('<br />'))}</p>`)\n }\n\n flushList()\n if (inCode && codeBuf.length) {\n blocks.push(\n `<pre class=\"overflow-x-auto rounded-lg bg-muted p-3 text-sm\"><code>${codeBuf.join('\\n')}</code></pre>`,\n )\n }\n\n return blocks.join('\\n')\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../src/config.ts","../src/utils/moderation-strike.ts","../src/composables/useMemiBoardAuth.ts","../src/utils/slugify.ts","../src/utils/extractEditorImageUrls.ts","../src/utils/storagePath.ts","../src/utils/postPreview.ts","../src/composables/useMemiBoardPosts.ts","../src/composables/useMemiBoardComments.ts","../src/utils/moderation-prompt.ts","../src/composables/useMemiBoardModeration.ts","../src/utils/compressImage.ts","../src/composables/useMemiBoardStorage.ts","../src/composables/useMemiBoardSettings.ts","../src/composables/useMemiBoardUsers.ts","../src/data/versionHistory.ts","../src/utils/formatDate.ts","../src/utils/renderMarkdown.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 * 콘텐츠 검열 차단(로컬/AI flagged) 누적 횟수 이상이 되면 글·댓글 작성 제한.\n * 기본 3. 0 이하면 제한 기능 off.\n */\n blockBanThreshold?: number\n /** 경고 1회 차감 간격(ms). 기본 24시간 */\n blockBanDecayMs?: number\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 blockBanThreshold: 3,\n blockBanDecayMs: 24 * 60 * 60 * 1000,\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","/**\n * 게시판 검열 차단 누적 → 일시 이용 제한 (호스트 contentWarning 과 동일 모델).\n * - 콘텐츠 차단(로컬/AI flagged) 시 +1\n * - 유효 경고 ≥ threshold(기본 3) → 글/댓글 작성 불가\n * - 24h마다 lazy 차감 (moderationBlockAt 기준)\n */\n\nexport const DEFAULT_BLOCK_BAN_THRESHOLD = 3\nexport const DEFAULT_BLOCK_BAN_DECAY_MS = 24 * 60 * 60 * 1000\n\nexport function effectiveModerationBlockCount(\n count: number | null | undefined,\n blockAtMs: number | null | undefined,\n nowMs: number = Date.now(),\n decayMs: number = DEFAULT_BLOCK_BAN_DECAY_MS,\n): number {\n const c = Math.max(0, Math.floor(Number(count) || 0))\n if (c <= 0) return 0\n const at = typeof blockAtMs === 'number' && Number.isFinite(blockAtMs) ? blockAtMs : null\n if (at == null) return c\n if (nowMs <= at) return c\n const decayed = Math.floor((nowMs - at) / decayMs)\n return Math.max(0, c - decayed)\n}\n\n/** 제한 해제 시각(ms). 제한 없으면 null. */\nexport function moderationWriteRestrictedUntilMs(\n count: number | null | undefined,\n blockAtMs: number | null | undefined,\n nowMs: number = Date.now(),\n threshold: number = DEFAULT_BLOCK_BAN_THRESHOLD,\n decayMs: number = DEFAULT_BLOCK_BAN_DECAY_MS,\n): number | null {\n const c = Math.max(0, Math.floor(Number(count) || 0))\n if (c < threshold) return null\n const at = typeof blockAtMs === 'number' && Number.isFinite(blockAtMs) ? blockAtMs : null\n if (at == null) return nowMs\n // 유효 경고가 threshold-1 이하가 되는 시점\n const until = at + (c - (threshold - 1)) * decayMs\n return until > nowMs ? until : null\n}\n\nexport function isModerationWriteRestricted(\n count: number | null | undefined,\n blockAtMs: number | null | undefined,\n nowMs: number = Date.now(),\n threshold: number = DEFAULT_BLOCK_BAN_THRESHOLD,\n decayMs: number = DEFAULT_BLOCK_BAN_DECAY_MS,\n): boolean {\n return moderationWriteRestrictedUntilMs(count, blockAtMs, nowMs, threshold, decayMs) != null\n}\n\nexport function formatRestrictedUntilLabel(untilMs: number, nowMs: number = Date.now()): string {\n const left = Math.max(0, untilMs - nowMs)\n const hours = Math.ceil(left / (60 * 60 * 1000))\n if (hours <= 0) return '곧 해제'\n if (hours < 24) return `약 ${hours}시간 후 해제`\n const days = Math.floor(hours / 24)\n const remH = hours % 24\n if (remH === 0) return `약 ${days}일 후 해제`\n return `약 ${days}일 ${remH}시간 후 해제`\n}\n\nexport function toBlockAtMs(value: unknown): number | null {\n if (value == null) return null\n if (typeof value === 'number' && Number.isFinite(value)) return value\n if (typeof value === 'object' && value !== null && 'toMillis' in value\n && typeof (value as { toMillis: () => number }).toMillis === 'function') {\n return (value as { toMillis: () => number }).toMillis()\n }\n if (typeof value === 'object' && value !== null && 'seconds' in value) {\n const s = Number((value as { seconds: number }).seconds)\n if (Number.isFinite(s)) return s * 1000\n }\n return null\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, increment, 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, BoardUserRole } from '../types'\nimport {\n DEFAULT_BLOCK_BAN_DECAY_MS,\n DEFAULT_BLOCK_BAN_THRESHOLD,\n effectiveModerationBlockCount,\n formatRestrictedUntilLabel,\n isModerationWriteRestricted,\n moderationWriteRestrictedUntilMs,\n toBlockAtMs,\n} from '../utils/moderation-strike'\n\nexport interface UseMemiBoardAuthReturn {\n user: ReturnType<typeof useCurrentUser>\n isSignedIn: ComputedRef<boolean>\n isAdmin: ComputedRef<boolean>\n isStaff: ComputedRef<boolean>\n canManageContent: ComputedRef<boolean>\n rolePending: Ref<boolean>\n /** 유효 검열 차단 누적 (lazy decay 반영) */\n moderationBlockCount: ComputedRef<number>\n /** 글·댓글 작성 제한 중 */\n isWriteRestricted: ComputedRef<boolean>\n restrictedUntilLabel: ComputedRef<string | null>\n restrictedMessage: ComputedRef<string | null>\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 /** 콘텐츠 검열 차단 1회 기록 후 메시지 조각 반환 */\n recordContentModerationBlock: () => Promise<{\n effectiveCount: number\n restricted: boolean\n messageSuffix: string\n }>\n refreshBoardUser: () => Promise<void>\n}\n\nconst AUTH_STATE_KEY = '__MEMI_BOARD_AUTH_STATE__' as const\n\ntype BoardAuthShared = {\n role: Ref<BoardUserRole>\n rolePending: Ref<boolean>\n rawBlockCount: Ref<number>\n blockAtMs: Ref<number | null>\n watchStarted: boolean\n}\n\nfunction getSharedAuthState(): BoardAuthShared {\n const g = globalThis as typeof globalThis & { [AUTH_STATE_KEY]?: BoardAuthShared }\n if (!g[AUTH_STATE_KEY]) {\n g[AUTH_STATE_KEY] = {\n role: ref<BoardUserRole>('user'),\n rolePending: ref(true),\n rawBlockCount: ref(0),\n blockAtMs: ref<number | null>(null),\n watchStarted: false,\n }\n }\n return g[AUTH_STATE_KEY]\n}\n\n/**\n * 인증 상태 + 게시판 역할(role) + 검열 차단 누적.\n * role·경고 카운트는 globalThis 공유 (여러 composable 호출이 같은 상태).\n */\nexport function useMemiBoardAuth(): UseMemiBoardAuthReturn {\n const config = useMemiBoardConfig()\n const shared = getSharedAuthState()\n const db = useFirestore()\n const auth = useFirebaseAuth()\n const user = useCurrentUser()\n\n const { role, rolePending, rawBlockCount, blockAtMs } = shared\n\n function banThreshold(): number {\n const t = config.moderation?.blockBanThreshold\n if (t === 0) return 0\n return typeof t === 'number' && t > 0 ? Math.floor(t) : DEFAULT_BLOCK_BAN_THRESHOLD\n }\n\n function banDecayMs(): number {\n const d = config.moderation?.blockBanDecayMs\n return typeof d === 'number' && d > 0 ? d : DEFAULT_BLOCK_BAN_DECAY_MS\n }\n\n function roleDocRef(uid: string) {\n return doc(db, `${config.collectionPrefix}Users`, uid)\n }\n\n function applyUserData(data: Record<string, unknown> | undefined) {\n if (!data) {\n rawBlockCount.value = 0\n blockAtMs.value = null\n return\n }\n rawBlockCount.value = Math.max(0, Math.floor(Number(data.moderationBlockCount) || 0))\n blockAtMs.value = toBlockAtMs(data.moderationBlockAt)\n }\n\n async function ensureUserDoc(firebaseUser: User): Promise<BoardUserRole> {\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 email: firebaseUser.email,\n photoURL: firebaseUser.photoURL,\n updatedAt: serverTimestamp(),\n })\n applyUserData({ moderationBlockCount: 0 })\n return 'user'\n }\n const data = snap.data() as Record<string, unknown>\n applyUserData(data)\n await updateDoc(ref, {\n displayName: firebaseUser.displayName,\n email: firebaseUser.email,\n photoURL: firebaseUser.photoURL,\n updatedAt: serverTimestamp(),\n })\n return (['admin', 'staff', 'user'].includes(String(data.role)) ? data.role : 'user') as BoardUserRole\n }\n\n async function refreshBoardUser() {\n const uid = user.value?.uid\n if (!uid) {\n applyUserData(undefined)\n return\n }\n const snap = await getDoc(roleDocRef(uid))\n applyUserData(snap.exists() ? snap.data() as Record<string, unknown> : undefined)\n }\n\n if (!shared.watchStarted) {\n shared.watchStarted = true\n watch(\n user,\n async (current) => {\n rolePending.value = true\n if (!current) {\n role.value = 'user'\n applyUserData(undefined)\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\n const isSignedIn = computed(() => !!user.value)\n const isAdmin = computed(() => role.value === 'admin')\n const isStaff = computed(() => role.value === 'staff')\n const canManageContent = computed(() => isAdmin.value || isStaff.value)\n\n const moderationBlockCount = computed(() =>\n effectiveModerationBlockCount(\n rawBlockCount.value,\n blockAtMs.value,\n Date.now(),\n banDecayMs(),\n ),\n )\n\n const isWriteRestricted = computed(() => {\n const th = banThreshold()\n if (th <= 0) return false\n return isModerationWriteRestricted(\n rawBlockCount.value,\n blockAtMs.value,\n Date.now(),\n th,\n banDecayMs(),\n )\n })\n\n const restrictedUntilLabel = computed(() => {\n const th = banThreshold()\n if (th <= 0) return null\n const until = moderationWriteRestrictedUntilMs(\n rawBlockCount.value,\n blockAtMs.value,\n Date.now(),\n th,\n banDecayMs(),\n )\n return until != null ? formatRestrictedUntilLabel(until) : null\n })\n\n const restrictedMessage = computed(() => {\n if (!isWriteRestricted.value) return null\n const until = restrictedUntilLabel.value\n return until\n ? `콘텐츠 경고가 누적되어 글·댓글 작성이 잠시 제한됐어요. (${until})`\n : '콘텐츠 경고가 누적되어 글·댓글 작성이 잠시 제한됐어요.'\n })\n\n async function recordContentModerationBlock() {\n const uid = user.value?.uid\n const threshold = banThreshold()\n if (!uid || threshold <= 0) {\n return { effectiveCount: 0, restricted: false, messageSuffix: '' }\n }\n\n const ref = roleDocRef(uid)\n const snap = await getDoc(ref)\n if (!snap.exists()) {\n await setDoc(ref, {\n role: 'user',\n displayName: user.value?.displayName ?? null,\n moderationBlockCount: 1,\n moderationBlockAt: serverTimestamp(),\n updatedAt: serverTimestamp(),\n })\n }\n else {\n await updateDoc(ref, {\n moderationBlockCount: increment(1),\n moderationBlockAt: serverTimestamp(),\n updatedAt: serverTimestamp(),\n })\n }\n\n // increment 직후 getDoc 이 로컬 캐시로 바로 반영됨\n const nextCount = (snap.exists()\n ? Math.max(0, Math.floor(Number((snap.data() as Record<string, unknown>).moderationBlockCount) || 0))\n : 0) + 1\n const nextAt = Date.now()\n rawBlockCount.value = nextCount\n blockAtMs.value = nextAt\n\n const effectiveCount = effectiveModerationBlockCount(\n nextCount,\n nextAt,\n nextAt,\n banDecayMs(),\n )\n const restricted = isModerationWriteRestricted(\n nextCount,\n nextAt,\n nextAt,\n threshold,\n banDecayMs(),\n )\n // 앞 사유 문장과 이어 붙일 접미 (공백·구분자 포함)\n let messageSuffix = ` (경고 ${effectiveCount}/${threshold})`\n if (restricted) {\n const until = moderationWriteRestrictedUntilMs(\n nextCount,\n nextAt,\n nextAt,\n threshold,\n banDecayMs(),\n )\n const untilLabel = until != null ? formatRestrictedUntilLabel(until, nextAt) : null\n messageSuffix = untilLabel\n ? ` 경고 ${effectiveCount}회가 되어 글·댓글 작성이 제한됩니다 (${untilLabel}).`\n : ` 경고 ${effectiveCount}회가 되어 글·댓글 작성이 제한됩니다.`\n }\n return { effectiveCount, restricted, messageSuffix }\n }\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 || canManageContent.value)\n }\n\n function canDeleteComment(comment: Pick<CommentModel, 'authorUid'>) {\n return isSignedIn.value && (user.value?.uid === comment.authorUid || canManageContent.value)\n }\n\n return {\n user,\n isSignedIn,\n isAdmin,\n isStaff,\n canManageContent,\n rolePending,\n moderationBlockCount,\n isWriteRestricted,\n restrictedUntilLabel,\n restrictedMessage,\n signInWithGoogle,\n signInWithApple,\n signInWithEmail,\n signUpWithEmail,\n signOut,\n canEdit,\n canDelete: canEdit,\n canDeleteComment,\n recordContentModerationBlock,\n refreshBoardUser,\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","/**\n * 에디터 본문(markdown 또는 html)에서 이미지 URL 추출.\n * 고아 파일 정리 스케줄러·저장 시 참조 비교에 사용.\n */\nexport function extractEditorImageUrls(content: string): string[] {\n if (!content) return []\n const urls = new Set<string>()\n\n for (const m of content.matchAll(/!\\[[^\\]]*]\\((https?:\\/\\/[^)\\s]+)\\)/g)) {\n if (m[1]) urls.add(m[1].replaceAll('&amp;', '&'))\n }\n for (const m of content.matchAll(/<img[^>]*\\ssrc=[\"'](https?:\\/\\/[^\"']+)[\"']/gi)) {\n if (m[1]) urls.add(m[1].replaceAll('&amp;', '&'))\n }\n\n return [...urls]\n}\n","/**\n * Firebase Storage download URL → object path.\n * 예: .../o/board%2Fposts%2Fid%2Fimages%2Fx.png?alt=media → board/posts/id/images/x.png\n */\nexport function storagePathFromDownloadUrl(url: string): string | null {\n try {\n const m = url.match(/\\/o\\/([^?]+)/)\n if (!m?.[1]) return null\n return decodeURIComponent(m[1])\n }\n catch {\n return null\n }\n}\n\n/** path 에서 posts/{ns}/... 의 ns 추출 */\nexport function postNamespaceFromStoragePath(path: string): string | null {\n const m = path.match(/(?:^|\\/)posts\\/([^/]+)\\//)\n return m?.[1] ?? null\n}\n","import type { Attachment } from '../types'\n\nfunction decodeHtmlAttribute(value: string): string {\n return value.replace(/&amp;/g, '&').replace(/&quot;/g, '\"').replace(/&#39;/g, \"'\")\n}\n\nfunction youtubeId(value: string): string | undefined {\n try {\n const url = new URL(decodeHtmlAttribute(value))\n const host = url.hostname.toLowerCase().replace(/^www\\./, '')\n if (host === 'youtu.be') return url.pathname.split('/').filter(Boolean)[0]\n if (host.endsWith('youtube.com') || host === 'youtube-nocookie.com') {\n if (url.pathname.startsWith('/embed/')) return url.pathname.split('/')[2]\n if (url.pathname.startsWith('/shorts/')) return url.pathname.split('/')[2]\n return url.searchParams.get('v') || undefined\n }\n }\n catch {\n return undefined\n }\n}\n\nexport function buildPostPreview(content: string, attachments: Attachment[] = []): {\n summary: string\n previewImage?: string\n videoUrl?: string\n} {\n const imageMatch = content.match(/<img\\b[^>]*\\bsrc=[\"']([^\"']+)[\"']/i)\n const iframeMatch = content.match(/<iframe\\b[^>]*\\bsrc=[\"']([^\"']+)[\"']/i)\n const attachmentImage = attachments.find(item => item.type.startsWith('image/'))?.url\n const attachmentVideo = attachments.find(item => item.type.startsWith('video/'))?.url\n const videoUrl = iframeMatch ? decodeHtmlAttribute(iframeMatch[1]!) : attachmentVideo\n const videoId = videoUrl ? youtubeId(videoUrl) : undefined\n const previewImage = imageMatch\n ? decodeHtmlAttribute(imageMatch[1]!)\n : attachmentImage || (videoId ? `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg` : undefined)\n const summary = content\n .replace(/<style[\\s\\S]*?<\\/style>/gi, ' ')\n .replace(/<script[\\s\\S]*?<\\/script>/gi, ' ')\n .replace(/<[^>]+>/g, ' ')\n .replace(/&nbsp;/g, ' ')\n .replace(/&amp;/g, '&')\n .replace(/\\s+/g, ' ')\n .trim()\n .slice(0, 160)\n\n return { summary, ...(previewImage ? { previewImage } : {}), ...(videoUrl ? { videoUrl } : {}) }\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 limitToLast,\n startAfter,\n endBefore,\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 { extractEditorImageUrls } from '../utils/extractEditorImageUrls'\nimport { postNamespaceFromStoragePath, storagePathFromDownloadUrl } from '../utils/storagePath'\nimport { useMemiBoardConfig } from '../config'\nimport { buildPostPreview } from '../utils/postPreview'\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\nasync function deleteStoragePaths(storage: ReturnType<typeof getStorage>, paths: Iterable<string>): Promise<void> {\n const unique = [...new Set([...paths].filter(Boolean))]\n await Promise.all(\n unique.map(path => deleteObject(storageRef(storage, path)).catch(() => {})),\n )\n}\n\nexport interface CreatePostInput {\n /** 작성 화면 진입 시 미리 만든 Firestore 자동 ID. Storage namespace와 동일하게 사용한다. */\n postId?: string\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 /** 문서를 쓰지 않고 Firestore 자동 ID만 미리 만든다. */\n function createPostId(): string {\n return doc(postsCol()).id\n }\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 /** 공개 URL의 category + slug로 내부 Firestore 문서를 찾는다. */\n async function getPostBySlug(category: string, slug: string): Promise<PostDetail | null> {\n const snapshot = await getDocs(query(\n postsCol(),\n where('category', '==', category),\n where('slug', '==', slug),\n fbLimit(1),\n ))\n const meta = snapshot.docs[0]\n return meta ? getPost(meta.id) : null\n }\n\n /** 현재 카테고리의 최신순 목록을 기준으로 인접한 이전(오래된)·다음(최신) 글을 조회한다. */\n async function getAdjacentPosts(current: PostModel): Promise<{\n previous: PostModel | null\n next: PostModel | null\n }> {\n if (!current.category || !current.createdAt) return { previous: null, next: null }\n const base: QueryConstraint[] = [\n where('category', '==', current.category),\n orderBy('createdAt', 'desc'),\n ]\n const [previousSnapshot, nextSnapshot] = await Promise.all([\n getDocs(query(postsCol(), ...base, startAfter(current.createdAt), fbLimit(1))),\n getDocs(query(postsCol(), ...base, endBefore(current.createdAt), limitToLast(1))),\n ])\n const mapPost = (snapshot: typeof previousSnapshot): PostModel | null => {\n const item = snapshot.docs[0]\n return item ? ({ id: item.id, ...item.data() } as PostModel) : null\n }\n return {\n previous: mapPost(previousSnapshot),\n next: mapPost(nextSnapshot),\n }\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 getDocs(query(\n postsCol(),\n where('category', '==', input.category || ''),\n where('slug', '==', slug),\n fbLimit(1),\n ))).empty) {\n counter++\n slug = `${baseSlug}-${counter}`\n }\n const id = input.postId || createPostId()\n\n const preview = buildPostPreview(input.content, input.attachments)\n await setDoc(postDoc(id), {\n slug,\n title: input.title,\n ...preview,\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 // body 규칙이 부모 post 소유자를 조회하므로 부모 문서 생성 후 저장한다.\n await setDoc(bodyDoc(id), { content: input.content })\n\n return slug\n }\n\n async function updatePost(id: string, input: UpdatePostInput): Promise<void> {\n // 메타·본문을 같이 갱신. category 미선택 시 필드 제거(부분 갱신으로 예전 값이 남는 것 방지).\n const preview = buildPostPreview(input.content, input.attachments)\n await Promise.all([\n updateDoc(postDoc(id), {\n title: input.title,\n ...preview,\n previewImage: preview.previewImage ? preview.previewImage : deleteField(),\n videoUrl: preview.videoUrl ? preview.videoUrl : deleteField(),\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 /**\n * 글 삭제 순서 (규칙: Storage 삭제는 부모 post 문서가 있을 때 소유권 검사 가능)\n * 1) 메타·본문 읽어 첨부 path / 본문 이미지 URL 수집\n * 2) 댓글 전부\n * 3) 본문 body\n * 4) Storage: posts/{id}/** (images + attachments)\n * 5) Storage: 게시글 자동 ID namespace의 첨부·본문 이미지 경로\n * 6) 메타 post 문서\n */\n async function deletePost(id: string): Promise<void> {\n const [metaSnap, bodySnap] = await Promise.all([\n getDoc(postDoc(id)),\n getDoc(bodyDoc(id)),\n ])\n if (!metaSnap.exists()) return\n\n const meta = metaSnap.data() as PostModel\n const bodyContent = bodySnap.exists()\n ? String((bodySnap.data() as { content?: string }).content ?? '')\n : ''\n\n // ── 댓글 ──────────────────────────────────────────\n const commentsSnap = await getDocs(commentsCol(id))\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 // ── Storage 정리용 path 수집 (post 문서 삭제 전) ──\n const storage = getStorage(app)\n const extraPaths = new Set<string>()\n const extraNamespaces = new Set<string>()\n\n for (const att of meta.attachments ?? []) {\n if (att?.path) {\n extraPaths.add(att.path)\n const ns = postNamespaceFromStoragePath(att.path)\n if (ns && ns !== id) extraNamespaces.add(ns)\n }\n }\n\n for (const url of extractEditorImageUrls(bodyContent)) {\n const path = storagePathFromDownloadUrl(url)\n if (!path) continue\n extraPaths.add(path)\n // 썸네일 쌍 (images/foo.png → images/thumbnails/foo.jpg 추정)\n const thumbGuess = path\n .replace(/\\/images\\/([^/]+)$/, '/images/thumbnails/$1')\n .replace(/\\.[^.]+$/, '.jpg')\n if (thumbGuess !== path && thumbGuess.includes('/images/thumbnails/')) {\n extraPaths.add(thumbGuess)\n }\n // 원본이 thumbnails 가 아니고 basename 이 다른 확장자인 경우 동일 basename .jpg\n const m = path.match(/^(.*\\/images\\/)([^/]+)\\.[^.]+$/)\n if (m && !path.includes('/thumbnails/')) {\n extraPaths.add(`${m[1]}thumbnails/${m[2]}.jpg`)\n }\n const ns = postNamespaceFromStoragePath(path)\n if (ns && ns !== id) extraNamespaces.add(ns)\n }\n\n // ── 본문 (규칙: body write 는 부모 post 소유권) ──\n await deleteDoc(bodyDoc(id))\n\n // ── Storage: 최종 id 폴더 전체 (images/**, attachments/**) ──\n await deleteStorageFolder(storageRef(storage, `${prefix()}/posts/${id}`))\n\n // ── 임시 네임스페이스 폴더 (작성 중 new-ts 에 올린 파일) ──\n for (const ns of extraNamespaces) {\n await deleteStorageFolder(storageRef(storage, `${prefix()}/posts/${ns}`))\n }\n\n // ── 폴더 삭제에 안 잡힌 개별 path (다른 prefix 등) ──\n await deleteStoragePaths(storage, extraPaths)\n\n // ── 메타 마지막 ──\n await deleteDoc(postDoc(id))\n }\n\n return {\n createPostId,\n getPosts,\n getPost,\n getPostBySlug,\n getAdjacentPosts,\n createPost,\n updatePost,\n deletePost,\n }\n}\n","import { computed, onScopeDispose, ref, watch } from 'vue'\nimport type { Ref } from 'vue'\nimport type { DocumentData, QueryDocumentSnapshot, Unsubscribe } from 'firebase/firestore'\nimport { useFirestore } from 'vuefire'\nimport {\n collection,\n doc,\n documentId,\n getDocs,\n increment,\n limit,\n onSnapshot,\n orderBy,\n query,\n serverTimestamp,\n startAfter,\n where,\n writeBatch,\n} 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\nexport interface AddReplyInput extends AddCommentInput {\n parent: CommentModel\n}\n\nconst COMMENT_PAGE_SIZE = 10\nconst REPLY_PAGE_SIZE = 5\n\n/** 10개씩 순차 조회한 뒤 마지막 페이지에서만 최신 1개를 실시간 구독한다. */\nexport function useMemiBoardComments(\n postId: string | Ref<string>,\n options: { subscribe?: boolean } = {},\n) {\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 // limit(1) 리스너는 최신 댓글 알림 용도다. 쿼리 결과 교체로 기존 댓글이\n // 사라지지 않도록 수신 문서를 별도 배열에 누적한다.\n const liveComments = ref<CommentModel[]>([])\n // parentId 도입 전 댓글은 실시간 10개 슬롯과 분리해 최초 1회만 읽는다.\n const legacyComments = ref<CommentModel[]>([])\n const legacyPending = ref(options.subscribe !== false)\n const pagedComments = ref<CommentModel[]>([])\n const loadingMore = ref(false)\n const hasMore = ref(options.subscribe !== false)\n const initialPending = ref(options.subscribe !== false)\n let pageCursor: QueryDocumentSnapshot<DocumentData> | null = null\n let liveEnableTimer: ReturnType<typeof setTimeout> | undefined\n let stopLiveSubscription: Unsubscribe | undefined\n\n function createdAtMs(comment: CommentModel): number {\n return comment.createdAt?.toMillis?.() ?? 0\n }\n\n const comments = computed(() => {\n const byId = new Map<string, CommentModel>()\n for (const comment of [...liveComments.value, ...legacyComments.value, ...pagedComments.value]) {\n if (comment.id) byId.set(comment.id, comment)\n }\n return [...byId.values()].sort((a, b) =>\n createdAtMs(a) - createdAtMs(b) || String(a.id).localeCompare(String(b.id)),\n )\n })\n\n watch(id, async (currentId) => {\n pagedComments.value = []\n liveComments.value = []\n legacyComments.value = []\n pageCursor = null\n clearTimeout(liveEnableTimer)\n liveEnableTimer = undefined\n stopLiveSubscription?.()\n stopLiveSubscription = undefined\n hasMore.value = options.subscribe !== false\n if (options.subscribe === false) return\n\n legacyPending.value = true\n initialPending.value = true\n try {\n const snapshotPromise = getDocs(query(\n collection(db, `${prefix()}Posts`, currentId, 'comments'),\n orderBy('createdAt', 'desc'),\n orderBy(documentId(), 'desc'),\n ))\n await Promise.all([snapshotPromise.then((snapshot) => {\n if (id.value !== currentId) return\n const allComments = snapshot.docs\n .map(item => ({ id: item.id, ...item.data() } as CommentModel))\n legacyComments.value = allComments.filter(comment => comment.parentId === undefined)\n }), loadMore()])\n if (id.value !== currentId) return\n }\n finally {\n if (id.value === currentId) {\n legacyPending.value = false\n initialPending.value = false\n }\n }\n }, { immediate: true })\n\n function startLiveSubscription() {\n stopLiveSubscription?.()\n stopLiveSubscription = onSnapshot(query(\n collection(db, `${prefix()}Posts`, id.value, 'comments'),\n where('parentId', '==', null),\n orderBy('createdAt', 'desc'),\n orderBy(documentId(), 'desc'),\n limit(1),\n ), (snapshot) => {\n const byId = new Map(liveComments.value.map(comment => [comment.id, comment]))\n for (const change of snapshot.docChanges()) {\n if (change.type === 'removed') continue\n byId.set(change.doc.id, { id: change.doc.id, ...change.doc.data() } as CommentModel)\n }\n liveComments.value = [...byId.values()]\n }, (cause) => {\n console.error('[memi-board:comments] onSnapshot failed', cause)\n })\n }\n\n async function loadMore(): Promise<void> {\n if (loadingMore.value || !hasMore.value) return\n loadingMore.value = true\n try {\n const constraints = [\n where('parentId', '==', null),\n orderBy('createdAt', 'asc'),\n orderBy(documentId(), 'asc'),\n ...(pageCursor ? [startAfter(pageCursor)] : []),\n limit(COMMENT_PAGE_SIZE + 1),\n ]\n const snapshot = await getDocs(query(\n collection(db, `${prefix()}Posts`, id.value, 'comments'),\n ...constraints,\n ))\n const pageDocs = snapshot.docs.slice(0, COMMENT_PAGE_SIZE)\n const page = pageDocs.map(item => ({ id: item.id, ...item.data() } as CommentModel))\n const existing = new Set(pagedComments.value.map(comment => comment.id))\n pagedComments.value.push(...page.filter(comment => !existing.has(comment.id)))\n pageCursor = pageDocs.at(-1) ?? pageCursor\n if (snapshot.size <= COMMENT_PAGE_SIZE) {\n hasMore.value = false\n liveEnableTimer = setTimeout(() => {\n liveEnableTimer = undefined\n startLiveSubscription()\n }, 1_000)\n }\n }\n finally {\n loadingMore.value = false\n }\n }\n\n onScopeDispose(() => {\n clearTimeout(liveEnableTimer)\n stopLiveSubscription?.()\n })\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 parentId: null,\n rootId: commentRef.id,\n depth: 0,\n replyToUid: null,\n replyToName: null,\n replyCount: 0,\n isReply: false,\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 addReply(input: AddReplyInput): Promise<void> {\n if (!input.parent.id) throw new Error('답글 대상 댓글을 찾을 수 없습니다.')\n const batch = writeBatch(db)\n const p = prefix()\n const commentsCol = collection(db, `${p}Posts`, id.value, 'comments')\n const replyRef = doc(commentsCol)\n const rootId = input.parent.parentId == null\n ? input.parent.id\n : input.parent.rootId\n if (!rootId) throw new Error('댓글 스레드를 찾을 수 없습니다.')\n const depth = Math.min((input.parent.depth ?? 0) + 1, 2)\n\n batch.set(replyRef, {\n postId: id.value,\n body: input.body,\n authorUid: input.authorUid,\n authorName: input.authorName,\n authorPhoto: input.authorPhoto,\n moderationStatus: 'approved',\n parentId: input.parent.id,\n rootId,\n depth,\n replyToUid: input.parent.authorUid,\n replyToName: input.parent.authorName,\n replyCount: 0,\n isReply: true,\n createdAt: serverTimestamp(),\n })\n batch.update(doc(commentsCol, rootId), { replyCount: increment(1) })\n batch.update(doc(db, `${p}Posts`, id.value), { commentCount: increment(1) })\n await batch.commit()\n }\n\n async function deleteComment(comment: CommentModel): Promise<void> {\n if (!comment.id) return\n if (comment.parentId == null && (comment.replyCount ?? 0) > 0) {\n throw new Error('답글이 있는 댓글은 현재 삭제할 수 없습니다.')\n }\n const batch = writeBatch(db)\n const p = prefix()\n batch.delete(doc(db, `${p}Posts`, id.value, 'comments', comment.id))\n if (comment.parentId && comment.rootId) {\n batch.update(doc(db, `${p}Posts`, id.value, 'comments', comment.rootId), { replyCount: increment(-1) })\n }\n batch.update(doc(db, `${p}Posts`, id.value), { commentCount: increment(-1) })\n await batch.commit()\n pagedComments.value = pagedComments.value.filter(item => item.id !== comment.id)\n legacyComments.value = legacyComments.value.filter(item => item.id !== comment.id)\n liveComments.value = liveComments.value.filter(item => item.id !== comment.id)\n }\n\n return {\n comments,\n commentsPending: computed(() => initialPending.value || legacyPending.value),\n hasMore,\n loadingMore,\n loadMore,\n addComment,\n addReply,\n deleteComment,\n }\n}\n\n/** 펼친 스레드의 답글을 5개씩 일회성 조회한다. */\nexport function useMemiBoardReplies(postId: string, rootId: string) {\n const config = useMemiBoardConfig()\n const db = useFirestore()\n const replies = ref<CommentModel[]>([])\n const loading = ref(false)\n const loaded = ref(false)\n const hasMore = ref(true)\n let cursor: QueryDocumentSnapshot<DocumentData> | null = null\n let stopLiveSubscription: Unsubscribe | undefined\n\n function sortReplies(list: CommentModel[]) {\n return list.sort((a, b) =>\n (a.createdAt?.toMillis?.() ?? 0) - (b.createdAt?.toMillis?.() ?? 0)\n || String(a.id).localeCompare(String(b.id)),\n )\n }\n\n function startLiveSubscription() {\n if (stopLiveSubscription) return\n stopLiveSubscription = onSnapshot(query(\n collection(db, `${config.collectionPrefix}Posts`, postId, 'comments'),\n where('rootId', '==', rootId),\n where('isReply', '==', true),\n orderBy('createdAt', 'desc'),\n orderBy(documentId(), 'desc'),\n limit(1),\n ), (snapshot) => {\n const byId = new Map(replies.value.map(reply => [reply.id, reply]))\n for (const change of snapshot.docChanges()) {\n if (change.type === 'removed') continue\n byId.set(change.doc.id, { id: change.doc.id, ...change.doc.data() } as CommentModel)\n }\n replies.value = sortReplies([...byId.values()])\n }, (cause) => {\n console.error('[memi-board:replies] onSnapshot failed', cause)\n })\n }\n\n async function loadMore(): Promise<void> {\n if (loading.value || !hasMore.value) return\n loading.value = true\n try {\n const constraints = [\n where('rootId', '==', rootId),\n where('isReply', '==', true),\n orderBy('createdAt', 'asc'),\n orderBy(documentId(), 'asc'),\n ...(cursor ? [startAfter(cursor)] : []),\n limit(REPLY_PAGE_SIZE + 1),\n ]\n const snapshot = await getDocs(query(\n collection(db, `${config.collectionPrefix}Posts`, postId, 'comments'),\n ...constraints,\n ))\n const existing = new Set(replies.value.map(reply => reply.id))\n const pageDocs = snapshot.docs.slice(0, REPLY_PAGE_SIZE)\n const page = pageDocs\n .map(item => ({ id: item.id, ...item.data() } as CommentModel))\n .filter(reply => !existing.has(reply.id))\n replies.value = sortReplies([...replies.value, ...page])\n cursor = pageDocs.at(-1) ?? cursor\n loaded.value = true\n if (snapshot.size <= REPLY_PAGE_SIZE) {\n hasMore.value = false\n startLiveSubscription()\n }\n }\n finally {\n loading.value = false\n }\n }\n\n function reset() {\n stopLiveSubscription?.()\n stopLiveSubscription = undefined\n replies.value = []\n cursor = null\n loaded.value = false\n hasMore.value = true\n }\n\n async function refresh(): Promise<void> {\n reset()\n await loadMore()\n }\n\n onScopeDispose(() => stopLiveSubscription?.())\n\n return { replies, loading, loaded, hasMore, loadMore, refresh }\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 (예: \"욕설(비속어)이 포함되어 게시할 수 없습니다.\", \"광고·스팸성 문구가 있어 게시할 수 없습니다.\")\n 욕설 단어 전체를 그대로 길게 인용하지 말고, 유형(욕설/혐오/스팸 등)을 분명히 밝힌다.\n- flagged=false면 category는 \"none\", reason은 \"\"`\n\n/** 사용자 안내용 카테고리 라벨 */\nexport const MODERATION_CATEGORY_LABELS: Record<string, string> = {\n abuse: '욕설·비속어·모욕',\n spam: '스팸·광고',\n adult: '선정적·성인 내용',\n violence: '폭력·위협',\n other: '부적절한 내용',\n none: '',\n}\n\n/** 차단 사유를 사용자에게 읽기 쉽게 정리 */\nexport function formatModerationUserReason(opts: {\n reason?: string\n category?: string\n via?: string\n localHit?: string\n}): string {\n if (opts.via === 'local' && opts.localHit) {\n const hit = opts.localHit.trim()\n // 짧은 금칙어만 표시 (너무 길면 유형만)\n if (hit.length > 0 && hit.length <= 12) {\n return `「${hit}」 같은 표현은 사용할 수 없습니다. 다른 말로 바꿔 주세요.`\n }\n return '욕설·비속어가 포함되어 게시할 수 없습니다. 표현을 바꿔 주세요.'\n }\n\n const raw = (opts.reason || '').trim()\n if (raw) return raw\n\n const cat = opts.category && opts.category !== 'none'\n ? MODERATION_CATEGORY_LABELS[opts.category] || opts.category\n : ''\n if (cat) return `${cat}이(가) 포함되어 게시할 수 없습니다. 내용을 수정해 주세요.`\n return '게시할 수 없는 내용이 포함되어 있습니다. 내용을 수정해 주세요.'\n}\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 {\n buildLocalBlockRegex,\n DEFAULT_LOCAL_BLOCKLIST,\n formatModerationUserReason,\n MODERATION_SYSTEM,\n parseModerationJson,\n} from '../utils/moderation-prompt'\nimport { useMemiBoardConfig } from '../config'\nimport { useMemiBoardAuth } from './useMemiBoardAuth'\nimport type { ModerationResult, ModerationVia } from '../types'\n\nconst APPROVED: ModerationResult = { flagged: false, category: 'none', reason: '', via: 'empty' }\n\n/**\n * 글쓰기 전 블로킹 검열.\n * 이용 제한 확인 → 로컬 비속어 → Firebase AI Logic(Gemini).\n * 콘텐츠 차단(local/ai) 시 boardUsers 경고 누적.\n */\nexport function useMemiBoardModeration() {\n const config = useMemiBoardConfig()\n const moderation = config.moderation ?? {}\n const app = useFirebaseApp()\n const {\n isWriteRestricted,\n restrictedMessage,\n recordContentModerationBlock,\n } = useMemiBoardAuth()\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 const hit = match[0] || ''\n return {\n flagged: true,\n category: 'abuse',\n reason: formatModerationUserReason({ via: 'local', localHit: hit, category: 'abuse' }),\n via: 'local',\n }\n }\n return null\n }\n\n function getModerationModel() {\n const modelName = moderation.model ?? 'gemini-3.5-flash-lite'\n const useLimited = moderation.useLimitedUseAppCheckTokens !== false\n const ai = getAI(app, {\n backend: new GoogleAIBackend(),\n useLimitedUseAppCheckTokens: useLimited,\n })\n return 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 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 /** 사유 + 경고 횟수를 한 문장으로 */\n function withUserFacingReason(result: ModerationResult, messageSuffix = ''): ModerationResult {\n const why = formatModerationUserReason({\n reason: result.reason,\n category: result.category,\n via: result.via,\n })\n // 예: 「시발」 같은 표현은… (경고 2/3)\n const reason = messageSuffix ? `${why}${messageSuffix}` : why\n return { ...result, reason }\n }\n\n /** 콘텐츠 위반(local/ai)만 경고 누적. API 오류·이용제한 자체는 제외. */\n async function applyStrikeIfContentBlock(result: ModerationResult): Promise<ModerationResult> {\n if (!result.flagged || result.error) return result\n if (result.via !== 'local' && result.via !== 'ai') return result\n try {\n const { messageSuffix } = await recordContentModerationBlock()\n return withUserFacingReason(result, messageSuffix)\n }\n catch (e) {\n console.warn('[memi-board] record moderation strike failed', e instanceof Error ? e.message : e)\n return withUserFacingReason(result)\n }\n }\n\n async function checkText(text: string): Promise<ModerationResult> {\n if (isWriteRestricted.value) {\n return {\n flagged: true,\n category: 'other',\n reason: restrictedMessage.value\n || '콘텐츠 경고가 누적되어 글·댓글 작성이 잠시 제한됐어요.',\n via: 'restricted',\n }\n }\n\n const trimmed = text.trim()\n if (!trimmed) {\n return { ...APPROVED, via: 'empty' as ModerationVia }\n }\n\n const local = localCheck(trimmed)\n if (local) return applyStrikeIfContentBlock(local)\n\n if (moderation.enabled === false) {\n return { ...APPROVED, via: 'disabled' }\n }\n\n try {\n const model = getModerationModel()\n const result = await model.generateContent(`심사할 텍스트:\\n\"\"\"${trimmed.slice(0, 4000)}\"\"\"`)\n const raw = result.response.text()\n const parsed = parseModerationJson(raw)\n if (!parsed) return errorResult()\n const out: ModerationResult = {\n flagged: parsed.flagged,\n category: parsed.category as ModerationResult['category'],\n reason: formatModerationUserReason({\n reason: parsed.reason,\n category: parsed.category,\n via: 'ai',\n }),\n via: 'ai',\n }\n if (out.flagged) return applyStrikeIfContentBlock(out)\n return out\n }\n catch (e) {\n console.warn('[memi-board] moderation AI failed', e instanceof Error ? e.message : e)\n return errorResult()\n }\n }\n\n async function checkImage(file: File): Promise<ModerationResult> {\n if (isWriteRestricted.value) {\n return {\n flagged: true,\n category: 'other',\n reason: restrictedMessage.value\n || '콘텐츠 경고가 누적되어 글·댓글 작성이 잠시 제한됐어요.',\n via: 'restricted',\n }\n }\n\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 = getModerationModel()\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) return errorResult()\n const out: ModerationResult = {\n flagged: parsed.flagged,\n category: parsed.category as ModerationResult['category'],\n reason: formatModerationUserReason({\n reason: parsed.reason,\n category: parsed.category,\n via: 'ai',\n }),\n via: 'ai',\n }\n if (out.flagged) return applyStrikeIfContentBlock(out)\n return out\n }\n catch (e) {\n console.warn('[memi-board] moderation AI image failed', e instanceof Error ? e.message : e)\n return errorResult()\n }\n }\n\n return { checkText, checkImage }\n}\n","/**\n * Canvas API로 썸네일용 이미지 압축 (shineb 와 동일).\n */\nexport async function compressImage(\n file: File | Blob,\n { maxWidth = 400, quality = 0.8 }: { maxWidth?: number, quality?: number } = {},\n): Promise<Blob> {\n return new Promise((resolve, reject) => {\n const img = new Image()\n const objectUrl = URL.createObjectURL(file)\n\n img.onload = () => {\n URL.revokeObjectURL(objectUrl)\n\n const ratio = Math.min(1, maxWidth / img.naturalWidth)\n const canvas = document.createElement('canvas')\n canvas.width = Math.round(img.naturalWidth * ratio)\n canvas.height = Math.round(img.naturalHeight * ratio)\n\n const ctx = canvas.getContext('2d')\n if (!ctx) {\n reject(new Error('Canvas를 사용할 수 없습니다'))\n return\n }\n ctx.drawImage(img, 0, 0, canvas.width, canvas.height)\n\n canvas.toBlob(\n blob => (blob ? resolve(blob) : reject(new Error('압축 실패'))),\n 'image/jpeg',\n quality,\n )\n }\n\n img.onerror = () => {\n URL.revokeObjectURL(objectUrl)\n reject(new Error('이미지 로드 실패'))\n }\n\n img.src = objectUrl\n })\n}\n","import {\n getStorage,\n ref as storageRef,\n uploadBytes,\n uploadBytesResumable,\n getDownloadURL,\n deleteObject,\n} from 'firebase/storage'\nimport type { UploadTask } from 'firebase/storage'\nimport { useFirebaseApp } from 'vuefire'\nimport { safeFileName } from '../utils/slugify'\nimport { compressImage } from '../utils/compressImage'\nimport { useMemiBoardConfig } from '../config'\nimport type { Attachment, EditorImageEntry } from '../types'\n\n/** 에디터 이미지 최대 크기 (바이트) */\nexport const EDITOR_IMAGE_MAX_BYTES = 5 * 1024 * 1024\n\nexport function useMemiBoardStorage() {\n const config = useMemiBoardConfig()\n const app = useFirebaseApp()\n const prefix = () => config.collectionPrefix\n\n /** postId는 작성 화면 진입 시 미리 생성한 Firestore 자동 ID를 사용한다. */\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 /**\n * 에디터 본문 이미지: 원본 + 400px JPEG 썸네일 (shineb 동일).\n * Storage:\n * `{prefix}/posts/{postId}/images/{ts}-{name}.ext`\n * `{prefix}/posts/{postId}/images/thumbnails/{ts}-{name}.jpg`\n */\n async function uploadEditorImage(file: File, postId: string): Promise<EditorImageEntry> {\n if (!file.type.startsWith('image/')) {\n throw new Error('이미지 파일만 업로드할 수 있습니다.')\n }\n if (file.size > EDITOR_IMAGE_MAX_BYTES) {\n throw new Error(`이미지는 ${EDITOR_IMAGE_MAX_BYTES / 1024 / 1024}MB 이하여야 합니다.`)\n }\n\n const storage = getStorage(app)\n const ext = (file.name.split('.').pop() || file.type.split('/')[1] || 'png')\n .replace(/[^a-zA-Z0-9]/g, '')\n .slice(0, 8) || 'png'\n const safeName = safeFileName(file.name.replace(/\\.[^.]+$/, '') || 'image', 40)\n const baseName = `${Date.now()}-${safeName || 'image'}`\n const ns = postId || `new-${Date.now()}`\n\n const originalPath = `${prefix()}/posts/${ns}/images/${baseName}.${ext}`\n const originalRef = storageRef(storage, originalPath)\n await uploadBytes(originalRef, file, {\n contentType: file.type || 'image/png',\n })\n const originalUrl = await getDownloadURL(originalRef)\n\n const thumbBlob = await compressImage(file, { maxWidth: 400, quality: 0.8 })\n const thumbnailPath = `${prefix()}/posts/${ns}/images/thumbnails/${baseName}.jpg`\n const thumbRef = storageRef(storage, thumbnailPath)\n await uploadBytes(thumbRef, thumbBlob, { contentType: 'image/jpeg' })\n const thumbnailUrl = await getDownloadURL(thumbRef)\n\n return { originalUrl, originalPath, thumbnailUrl, thumbnailPath }\n }\n\n /** 원본·썸네일 쌍 삭제 (고아 정리·첨부 삭제용) */\n async function deleteEditorImage(entry: Pick<EditorImageEntry, 'originalPath' | 'thumbnailPath'>): Promise<void> {\n const storage = getStorage(app)\n await Promise.all([\n deleteObject(storageRef(storage, entry.originalPath)).catch(() => {}),\n deleteObject(storageRef(storage, entry.thumbnailPath)).catch(() => {}),\n ])\n }\n\n return {\n uploadAttachment,\n deleteAttachment,\n uploadEditorImage,\n deleteEditorImage,\n }\n}\n","import { computed } from 'vue'\nimport { useCollection, useFirestore } from 'vuefire'\nimport { collection, deleteDoc, doc, orderBy, query, serverTimestamp, setDoc, writeBatch } from 'firebase/firestore'\nimport { useMemiBoardConfig } from '../config'\nimport { useMemiBoardAuth } from './useMemiBoardAuth'\nimport { slugify } from '../utils/slugify'\nimport type { BoardCategory } from '../types'\n\n/** 필요할 때 호스트가 명시적으로 사용할 수 있는 예시 카테고리. 자동 적용하지 않는다. */\nexport const DEFAULT_CATEGORIES: BoardCategory[] = [\n { id: 'free', label: '자유', listView: 'default', writeRole: 'user', order: 0 },\n { id: 'notice', label: '공지', listView: 'default', writeRole: 'admin', order: 1 },\n { id: 'question', label: '질문', listView: 'default', writeRole: 'user', order: 2 },\n]\n\n/** 게시판 카테고리 — `{prefix}Settings/config/categories/{categoryId}` 개별 문서. */\nexport function useMemiBoardSettings() {\n const config = useMemiBoardConfig()\n const db = useFirestore()\n const { isSignedIn } = useMemiBoardAuth()\n\n const settingsRef = computed(() => doc(db, `${config.collectionPrefix}Settings`, 'config'))\n const categoriesRef = computed(() =>\n collection(db, `${config.collectionPrefix}Settings`, 'config', 'categories'),\n )\n const categoriesQuery = computed(() => query(categoriesRef.value, orderBy('order', 'asc')))\n // VueFire는 Query의 SSR state key를 경로만으로 만들 수 없으므로 명시해야\n // 서버 렌더 결과와 클라이언트 hydration이 같은 데이터를 사용한다.\n const { data: categoryDocs, pending: settingsPending } = useCollection<BoardCategory>(categoriesQuery, {\n ssrKey: `${config.collectionPrefix}Settings/config/categories`,\n })\n const categories = computed<BoardCategory[]>(() => categoryDocs.value.map((item, index) => ({\n ...item,\n id: item.id,\n order: typeof item.order === 'number' ? item.order : index,\n listView: item.listView ?? 'default',\n writeRole: item.writeRole ?? 'user',\n })))\n\n function categoryLabel(id: string | undefined): string | undefined {\n if (!id) return undefined\n return categories.value.find(category => category.id === id)?.label ?? id\n }\n\n async function saveCategory(category: BoardCategory, order = category.order ?? 0): Promise<void> {\n if (!isSignedIn.value) throw new Error('로그인이 필요합니다.')\n const id = category.id.trim()\n const label = category.label.trim()\n if (!id || !label) throw new Error('카테고리 ID와 이름을 입력해 주세요.')\n await setDoc(settingsRef.value, { updatedAt: serverTimestamp() }, { merge: true })\n await setDoc(doc(categoriesRef.value, id), {\n label,\n listView: category.listView ?? 'default',\n writeRole: category.writeRole ?? 'user',\n order,\n updatedAt: serverTimestamp(),\n }, { merge: true })\n }\n\n /** 호환용 일괄 저장. 각 카테고리는 독립 문서로 batch 저장한다. */\n async function saveCategories(next: BoardCategory[]): Promise<void> {\n if (!isSignedIn.value) throw new Error('로그인이 필요합니다.')\n const batch = writeBatch(db)\n batch.set(settingsRef.value, { updatedAt: serverTimestamp() }, { merge: true })\n next.forEach((category, order) => {\n const id = category.id.trim()\n const label = category.label.trim()\n if (!id || !label) throw new Error('카테고리 ID와 이름을 입력해 주세요.')\n batch.set(doc(categoriesRef.value, id), {\n label,\n listView: category.listView ?? 'default',\n writeRole: category.writeRole ?? 'user',\n order,\n updatedAt: serverTimestamp(),\n }, { merge: true })\n })\n await batch.commit()\n }\n\n async function deleteCategory(id: string): Promise<void> {\n if (!isSignedIn.value) throw new Error('로그인이 필요합니다.')\n await deleteDoc(doc(categoriesRef.value, id))\n }\n\n async function ensureSettings(): Promise<void> {\n return Promise.resolve()\n }\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 const existing = categories.value.find(category => category.label === trimmed)\n if (existing) return existing.id\n\n const base = slugify(trimmed) || `cat-${Date.now()}`\n let id = base\n let suffix = 2\n const used = new Set(categories.value.map(category => category.id))\n while (used.has(id)) id = `${base}-${suffix++}`\n await saveCategory({ id, label: trimmed, listView: 'default', writeRole: 'user', order: categories.value.length })\n return id\n }\n\n return {\n categories,\n categoryLabel,\n settingsPending,\n ensureSettings,\n addCategory,\n saveCategory,\n saveCategories,\n deleteCategory,\n DEFAULT_CATEGORIES,\n }\n}\n","import { collection, doc, serverTimestamp, updateDoc } from 'firebase/firestore'\nimport { useCollection, useFirestore } from 'vuefire'\nimport { useMemiBoardConfig } from '../config'\nimport type { BoardUserModel, BoardUserRole } from '../types'\n\nexport const BOARD_USER_ROLES: Array<{\n value: BoardUserRole\n label: string\n description: string\n}> = [\n { value: 'admin', label: '관리자', description: '사용자 역할, 게시판 설정과 모든 콘텐츠를 관리합니다.' },\n { value: 'staff', label: '스태프', description: '모든 게시글과 댓글을 관리하지만 사용자 역할과 게시판 설정은 바꿀 수 없습니다.' },\n { value: 'user', label: '사용자', description: '글과 댓글을 작성하고 본인이 작성한 콘텐츠만 수정·삭제할 수 있습니다.' },\n]\n\nexport function useMemiBoardUsers() {\n const config = useMemiBoardConfig()\n const db = useFirestore()\n const collectionName = `${config.collectionPrefix}Users`\n const users = useCollection<BoardUserModel>(collection(db, collectionName), {\n ssrKey: `${collectionName}-management`,\n })\n\n async function updateUserRole(uid: string, role: BoardUserRole) {\n if (!BOARD_USER_ROLES.some(item => item.value === role)) throw new Error('지원하지 않는 역할입니다.')\n await updateDoc(doc(db, collectionName, uid), { role, updatedAt: serverTimestamp() })\n }\n\n return { users, usersPending: users.pending, updateUserRole }\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.10.0',\n date: '2026-08-05',\n highlights: [\n 'Nuxt 설정 한 곳에서 게시판 설치와 Firebase 연결을 모두 설정할 수 있어요',\n '설치 프로젝트가 TipTap과 Day.js의 세부 번들 설정을 직접 맞추지 않아도 돼요',\n '게시글 목록 이동을 NuxtLink로 통일해 SSR과 CSR 환경에서 안정적으로 열려요',\n '게시판 아래에서 제작 정보와 사용 기술, 지금까지의 버전 이야기를 볼 수 있어요',\n ],\n },\n {\n version: '0.9.1',\n date: '2026-08-04',\n highlights: ['npm으로 설치한 프로젝트에서 사용자 관리 화면이 정상적으로 빌드돼요'],\n },\n {\n version: '0.9.0',\n date: '2026-08-04',\n highlights: [\n '댓글에 답글을 달고 대화 흐름을 최대 2단계로 볼 수 있어요',\n '댓글 10개·답글 5개 단위로 이어서 보고 마지막부터 새 댓글을 실시간으로 받아요',\n '더보기 버튼이 화면에 들어오면 로딩 표시와 함께 다음 댓글을 자동으로 불러와요',\n ],\n },\n {\n version: '0.8.0',\n date: '2026-08-04',\n highlights: [\n '최근 댓글 5개는 실시간으로 보고 이전 댓글은 5개씩 불러올 수 있어요',\n '댓글 시간이 상대시간으로 표시되고 전체 시간은 툴팁으로 확인할 수 있어요',\n '댓글 작성 영역을 대화 흐름의 맨 아래로 옮겼어요',\n ],\n },\n {\n version: '0.7.0',\n date: '2026-08-04',\n highlights: [\n '카테고리마다 글쓰기 권한을 일반 이상·스태프 이상·관리자만으로 설정할 수 있어요',\n '게시판 설정 화면의 SSR hydration 경고를 해결했어요',\n ],\n },\n {\n version: '0.6.0',\n date: '2026-08-04',\n highlights: [\n '관리자가 게시판 사용자를 확인하고 관리자·스태프·사용자 역할을 설정할 수 있어요',\n '스태프는 게시판 설정이나 사용자 역할은 바꾸지 않고 게시글과 댓글을 관리할 수 있어요',\n ],\n },\n {\n version: '0.5.0',\n date: '2026-08-04',\n highlights: [\n '게시판마다 일반·이미지·영상 목록 화면을 선택할 수 있어요',\n 'YouTube 링크를 붙여넣으면 본문에 영상으로 표시돼요',\n '게시글 아래에서 이전 글·목록·다음 글로 이동할 수 있어요',\n '관리자가 카테고리를 각각 저장하고 관리할 수 있어요',\n ],\n },\n {\n version: '0.4.15',\n date: '2026-08-03',\n highlights: ['설정 화면이 설치된 프로젝트에서 정상적으로 열리도록 고쳤어요'],\n },\n {\n version: '0.4.14',\n date: '2026-08-03',\n highlights: [\n '관리자가 카테고리 이름과 순서를 한 화면에서 관리할 수 있어요',\n '카테고리마다 일반·이미지·영상 목록 방식을 선택할 수 있어요',\n ],\n },\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","/**\n * 게시글 상세용 가벼운 마크다운 → HTML (TipTap 없이).\n * 목록 이동 시 UEditor destroy/recreate 로 나는\n * \"Adding different instances of a keyed plugin\" 를 피한다.\n * 신뢰된 작성자 콘텐츠 전제 — 기본 이스케이프 후 허용 패턴만 복원.\n */\n\nfunction escapeHtml(s: string): string {\n return s\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n}\n\n/** 인라인 마크다운 (이미 escape 된 문자열에 적용) */\nfunction formatInline(s: string): string {\n let out = s\n // code\n out = out.replace(/`([^`]+)`/g, '<code>$1</code>')\n // images ![alt](url) — url 은 http(s) 만\n out = out.replace(\n /!\\[([^\\]]*)\\]\\((https?:\\/\\/[^)\\s]+)\\)/g,\n '<img src=\"$2\" alt=\"$1\" class=\"max-w-full h-auto rounded-lg my-3\" loading=\"lazy\" />',\n )\n // links [text](url)\n out = out.replace(\n /\\[([^\\]]+)\\]\\((https?:\\/\\/[^)\\s]+)\\)/g,\n '<a href=\"$2\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-primary underline\">$1</a>',\n )\n // bold ** ** 먼저\n out = out.replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>')\n // italic * * (bold 처리 후 남은 단일 *)\n out = out.replace(/\\*([^*]+)\\*/g, '<em>$1</em>')\n // strikethrough ~~\n out = out.replace(/~~([^~]+)~~/g, '<del>$1</del>')\n return out\n}\n\n/**\n * GFM-ish 최소 변환. 코드 펜스·목록·제목·인용·hr·단락.\n */\nexport function renderMarkdownToHtml(markdown: string): string {\n if (!markdown?.trim()) return ''\n\n const lines = markdown.replace(/\\r\\n/g, '\\n').split('\\n')\n const blocks: string[] = []\n let i = 0\n let inCode = false\n let codeLang = ''\n let codeBuf: string[] = []\n let listType: 'ul' | 'ol' | null = null\n let listItems: string[] = []\n\n function flushList() {\n if (!listType || !listItems.length) {\n listType = null\n listItems = []\n return\n }\n const tag = listType\n blocks.push(`<${tag}>${listItems.map(li => `<li>${formatInline(li)}</li>`).join('')}</${tag}>`)\n listType = null\n listItems = []\n }\n\n while (i < lines.length) {\n const line = lines[i] ?? ''\n\n // fenced code\n const fence = line.match(/^```(\\w*)\\s*$/)\n if (fence) {\n if (inCode) {\n blocks.push(\n `<pre class=\"overflow-x-auto rounded-lg bg-muted p-3 text-sm\"><code>${codeBuf.join('\\n')}</code></pre>`,\n )\n codeBuf = []\n inCode = false\n codeLang = ''\n }\n else {\n flushList()\n inCode = true\n codeLang = fence[1] || ''\n void codeLang\n }\n i++\n continue\n }\n if (inCode) {\n codeBuf.push(escapeHtml(line))\n i++\n continue\n }\n\n // hr\n if (/^(-{3,}|\\*{3,}|_{3,})\\s*$/.test(line)) {\n flushList()\n blocks.push('<hr class=\"my-4 border-default\" />')\n i++\n continue\n }\n\n // heading\n const h = line.match(/^(#{1,3})\\s+(.+)$/)\n if (h) {\n flushList()\n const level = h[1]!.length\n blocks.push(`<h${level} class=\"font-semibold mt-4 mb-2\">${formatInline(escapeHtml(h[2]!))}</h${level}>`)\n i++\n continue\n }\n\n // blockquote\n if (line.startsWith('> ')) {\n flushList()\n const quoteLines: string[] = []\n while (i < lines.length && (lines[i] ?? '').startsWith('> ')) {\n quoteLines.push(escapeHtml((lines[i] ?? '').slice(2)))\n i++\n }\n blocks.push(\n `<blockquote class=\"border-l-4 border-default pl-3 text-muted my-3\">${formatInline(quoteLines.join('<br />'))}</blockquote>`,\n )\n continue\n }\n\n // unordered list\n const ul = line.match(/^[-*+]\\s+(.+)$/)\n if (ul) {\n if (listType && listType !== 'ul') flushList()\n listType = 'ul'\n listItems.push(escapeHtml(ul[1]!))\n i++\n continue\n }\n\n // ordered list\n const ol = line.match(/^\\d+\\.\\s+(.+)$/)\n if (ol) {\n if (listType && listType !== 'ol') flushList()\n listType = 'ol'\n listItems.push(escapeHtml(ol[1]!))\n i++\n continue\n }\n\n // empty line\n if (!line.trim()) {\n flushList()\n i++\n continue\n }\n\n // paragraph (merge consecutive non-empty)\n flushList()\n const para: string[] = []\n while (i < lines.length) {\n const l = lines[i] ?? ''\n if (!l.trim()) break\n if (/^(#{1,3}\\s|```|[-*+]\\s|\\d+\\.\\s|> |(-{3,}|\\*{3,})\\s*$)/.test(l)) break\n para.push(escapeHtml(l))\n i++\n }\n blocks.push(`<p class=\"my-2 leading-relaxed\">${formatInline(para.join('<br />'))}</p>`)\n }\n\n flushList()\n if (inCode && codeBuf.length) {\n blocks.push(\n `<pre class=\"overflow-x-auto rounded-lg bg-muted p-3 text-sm\"><code>${codeBuf.join('\\n')}</code></pre>`,\n )\n }\n\n return blocks.join('\\n')\n}\n"],"mappings":"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"}
package/dist/module.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { NuxtModule } from '@nuxt/schema'
2
+ import type { MemiBoardConfig } from './index.js'
2
3
 
3
- declare const module: NuxtModule<Record<string, never>>
4
+ declare const module: NuxtModule<Partial<MemiBoardConfig>>
4
5
  export default module