memi-board 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -28
- package/dist/index.d.ts +2 -8
- package/dist/index.js +162 -814
- package/dist/index.js.map +1 -1
- package/dist/module.d.ts +4 -0
- package/dist/module.mjs +62 -0
- package/dist/module.mjs.map +1 -0
- package/dist/runtime/components/Attachments.vue +173 -0
- package/dist/runtime/components/CommentForm.vue +83 -0
- package/dist/runtime/components/CommentList.vue +64 -0
- package/dist/runtime/components/Detail.vue +156 -0
- package/dist/runtime/components/Editor.vue +168 -0
- package/dist/runtime/components/List.vue +121 -0
- package/dist/runtime/components/SignIn.vue +146 -0
- package/dist/runtime/components/VersionHistory.vue +36 -0
- package/package.json +35 -9
- package/dist/components/Attachments.vue.d.ts +0 -17
- package/dist/components/CommentForm.vue.d.ts +0 -5
- package/dist/components/CommentList.vue.d.ts +0 -5
- package/dist/components/Detail.vue.d.ts +0 -11
- package/dist/components/Editor.vue.d.ts +0 -12
- package/dist/components/List.vue.d.ts +0 -14
- package/dist/components/SignIn.vue.d.ts +0 -2
- package/dist/components/VersionHistory.vue.d.ts +0 -2
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../src/config.ts","../src/composables/useMemiBoardAuth.ts","../src/utils/slugify.ts","../src/composables/useMemiBoardPosts.ts","../src/composables/useMemiBoardComments.ts","../src/utils/moderation-prompt.ts","../src/composables/useMemiBoardModeration.ts","../src/composables/useMemiBoardStorage.ts","../src/composables/useMemiBoardSettings.ts","../src/utils/formatDate.ts","../src/components/List.vue","../src/components/List.vue","../src/components/Attachments.vue","../src/components/Attachments.vue","../src/components/CommentForm.vue","../src/components/CommentForm.vue","../src/components/CommentList.vue","../src/components/CommentList.vue","../src/components/Detail.vue","../src/components/Detail.vue","../src/components/Editor.vue","../src/components/Editor.vue","../src/components/SignIn.vue","../src/components/SignIn.vue","../src/data/versionHistory.ts","../src/components/VersionHistory.vue","../src/components/VersionHistory.vue"],"sourcesContent":["import { reactive } from 'vue'\n\nexport interface MemiBoardAuthOptions {\n providers?: Array<'google' | 'apple' | 'emailPassword'>\n}\n\nexport interface MemiBoardModerationOptions {\n enabled?: boolean\n model?: string\n localBlocklist?: string[]\n onError?: 'allow' | 'block'\n moderateImages?: boolean\n}\n\nexport interface MemiBoardConfig {\n collectionPrefix: string\n auth: MemiBoardAuthOptions\n moderation: MemiBoardModerationOptions\n}\n\nconst config = reactive<MemiBoardConfig>({\n collectionPrefix: 'memiBoard',\n auth: { providers: ['google', 'apple'] },\n moderation: { enabled: true, model: 'gemini-2.5-flash', onError: 'allow', moderateImages: false, localBlocklist: [] },\n})\n\n/**\n * 앱 부팅 시(플러그인 등에서) 한 번 호출한다. 모든 요청/세션이 같은 값을 보므로\n * 서버 렌더링에서도 안전하다 — 사용자별/요청별로 달라지는 값이 아니다.\n */\nexport function configureMemiBoard(options: Partial<MemiBoardConfig>): void {\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 config\n}\n","import { ref, computed, watch } from 'vue'\nimport type { Ref, ComputedRef } from 'vue'\nimport { useFirestore, useFirebaseAuth, useCurrentUser } from 'vuefire'\nimport { doc, getDoc, setDoc, updateDoc, serverTimestamp } from 'firebase/firestore'\nimport {\n GoogleAuthProvider,\n OAuthProvider,\n signInWithPopup,\n signInWithEmailAndPassword,\n createUserWithEmailAndPassword,\n signOut as firebaseSignOut,\n updateProfile,\n} from 'firebase/auth'\nimport type { User } from 'firebase/auth'\nimport { useMemiBoardConfig } from '../config'\nimport type { PostModel, CommentModel } from '../types'\n\nexport interface UseMemiBoardAuthReturn {\n user: ReturnType<typeof useCurrentUser>\n isSignedIn: ComputedRef<boolean>\n isAdmin: ComputedRef<boolean>\n rolePending: Ref<boolean>\n signInWithGoogle: () => ReturnType<typeof signInWithPopup>\n signInWithApple: () => ReturnType<typeof signInWithPopup>\n signInWithEmail: (email: string, password: string) => ReturnType<typeof signInWithEmailAndPassword>\n signUpWithEmail: (email: string, password: string, displayName?: string) => ReturnType<typeof createUserWithEmailAndPassword>\n signOut: () => Promise<void>\n canEdit: (post: Pick<PostModel, 'authorUid'>) => boolean\n canDelete: (post: Pick<PostModel, 'authorUid'>) => boolean\n canDeleteComment: (comment: Pick<CommentModel, 'authorUid'>) => boolean\n}\n\n/**\n * 인증 상태 + 게시판 역할(role) 관리.\n * role은 {prefix}Users/{uid} 문서에서만 읽고, 모듈은 절대 'admin'을 쓰지 않는다\n * (관리자 승격은 호스트 프로젝트 소유자가 Firebase 콘솔에서 직접 설정).\n */\nexport function useMemiBoardAuth(): UseMemiBoardAuthReturn {\n const config = useMemiBoardConfig()\n const db = useFirestore()\n const auth = useFirebaseAuth()\n const user = useCurrentUser()\n\n const role = ref<'user' | 'admin'>('user')\n const rolePending = ref(true)\n\n function roleDocRef(uid: string) {\n return doc(db, `${config.collectionPrefix}Users`, uid)\n }\n\n /** 최초 로그인 시 역할 문서를 생성(role: 'user' 고정)하고, 이후에는 표시용 정보만 갱신한다. */\n async function ensureUserDoc(firebaseUser: User): Promise<'user' | 'admin'> {\n const ref = roleDocRef(firebaseUser.uid)\n const snap = await getDoc(ref)\n if (!snap.exists()) {\n await setDoc(ref, {\n role: 'user',\n displayName: firebaseUser.displayName,\n updatedAt: serverTimestamp(),\n })\n return 'user'\n }\n await updateDoc(ref, {\n displayName: firebaseUser.displayName,\n updatedAt: serverTimestamp(),\n })\n return (snap.data().role as 'user' | 'admin') ?? 'user'\n }\n\n watch(\n user,\n async (current) => {\n rolePending.value = true\n if (!current) {\n role.value = 'user'\n rolePending.value = false\n return\n }\n try {\n role.value = await ensureUserDoc(current)\n }\n catch {\n role.value = 'user'\n }\n finally {\n rolePending.value = false\n }\n },\n { immediate: true },\n )\n\n const isSignedIn = computed(() => !!user.value)\n const isAdmin = computed(() => role.value === 'admin')\n\n function requireAuth() {\n if (!auth) throw new Error('[memi-board] Firebase Auth가 초기화되지 않았습니다.')\n return auth\n }\n\n async function signInWithGoogle() {\n return signInWithPopup(requireAuth(), new GoogleAuthProvider())\n }\n\n async function signInWithApple() {\n const provider = new OAuthProvider('apple.com')\n provider.addScope('email')\n provider.addScope('name')\n return signInWithPopup(requireAuth(), provider)\n }\n\n async function signInWithEmail(email: string, password: string) {\n return signInWithEmailAndPassword(requireAuth(), email, password)\n }\n\n async function signUpWithEmail(email: string, password: string, displayName?: string) {\n const credential = await createUserWithEmailAndPassword(requireAuth(), email, password)\n if (displayName) await updateProfile(credential.user, { displayName })\n return credential\n }\n\n async function signOut() {\n await firebaseSignOut(requireAuth())\n }\n\n function canEdit(post: Pick<PostModel, 'authorUid'>) {\n return isSignedIn.value && (user.value?.uid === post.authorUid || isAdmin.value)\n }\n\n function canDeleteComment(comment: Pick<CommentModel, 'authorUid'>) {\n return isSignedIn.value && (user.value?.uid === comment.authorUid || isAdmin.value)\n }\n\n return {\n user,\n isSignedIn,\n isAdmin,\n rolePending,\n signInWithGoogle,\n signInWithApple,\n signInWithEmail,\n signUpWithEmail,\n signOut,\n canEdit,\n canDelete: canEdit,\n canDeleteComment,\n }\n}\n","/**\n * 제목 문자열을 URL-friendly slug로 변환\n * e.g. \"게시판 모듈 만들기!!\" → \"게시판-모듈-만들기\"\n */\nexport function slugify(text: string): string {\n return text\n .trim()\n .replace(/\\s+/g, '-') // 공백(연속 포함) → 하이픈\n .replace(/[^a-zA-Z0-9가-힣ㄱ-ㅎㅏ-ㅣ-]/g, '') // 허용 문자 외 제거\n .replace(/-+/g, '-') // 연속 하이픈 → 단일 하이픈\n .replace(/^-+|-+$/g, '') // 앞뒤 하이픈 제거\n}\n\n/** Storage 경로/파일명에 안전한 문자열로 변환 (한글은 유지) */\nexport function safeFileName(name: string, maxLength = 60): string {\n return name.replace(/[^a-zA-Z0-9가-힣ㄱ-ㅎ._-]/g, '-').slice(0, maxLength)\n}\n","import {\n collection,\n doc,\n getDoc,\n getDocs,\n setDoc,\n updateDoc,\n deleteDoc,\n query,\n orderBy,\n limit as fbLimit,\n startAfter,\n serverTimestamp,\n writeBatch,\n} from 'firebase/firestore'\nimport type { DocumentData, QueryDocumentSnapshot } from 'firebase/firestore'\nimport { getStorage, ref as storageRef, listAll, deleteObject } from 'firebase/storage'\nimport type { StorageReference } from 'firebase/storage'\nimport { useFirebaseApp, useFirestore } from 'vuefire'\nimport { slugify } from '../utils/slugify'\nimport { useMemiBoardConfig } from '../config'\nimport type { Attachment, PostDetail, PostModel } from '../types'\n\nasync function deleteStorageFolder(folderRef: StorageReference): Promise<void> {\n const list = await listAll(folderRef).catch(() => ({ items: [], prefixes: [] }))\n await Promise.all([\n ...list.items.map(item => deleteObject(item)),\n ...list.prefixes.map(prefix => deleteStorageFolder(prefix)),\n ])\n}\n\nexport interface CreatePostInput {\n title: string\n content: string\n tags?: string[]\n category?: string\n attachments?: Attachment[]\n authorUid: string\n authorName: string | null\n authorPhoto: string | null\n moderationModel?: string\n}\n\nexport interface UpdatePostInput {\n title: string\n content: string\n tags?: string[]\n category?: string\n attachments?: Attachment[]\n}\n\n/** 목록 조회는 posts/{id} 메타만 읽고, 본문(body/main)은 상세 조회 시에만 읽는다. */\nexport function useMemiBoardPosts() {\n const config = useMemiBoardConfig()\n const db = useFirestore()\n const app = useFirebaseApp()\n const prefix = config.collectionPrefix\n\n const postsCol = () => collection(db, `${prefix}Posts`)\n const postDoc = (id: string) => doc(db, `${prefix}Posts`, id)\n const bodyDoc = (id: string) => doc(db, `${prefix}Posts`, id, 'body', 'main')\n const commentsCol = (id: string) => collection(db, `${prefix}Posts`, id, 'comments')\n\n async function getPosts(opts: { pageSize?: number, cursor?: QueryDocumentSnapshot<DocumentData> } = {}) {\n const pageSize = opts.pageSize ?? 20\n const constraints = opts.cursor\n ? [orderBy('createdAt', 'desc'), startAfter(opts.cursor), fbLimit(pageSize + 1)]\n : [orderBy('createdAt', 'desc'), fbLimit(pageSize + 1)]\n const snapshot = await getDocs(query(postsCol(), ...constraints))\n const docs = snapshot.docs.slice(0, pageSize)\n return {\n posts: docs.map(d => ({ id: d.id, ...d.data() }) as PostModel),\n cursor: docs[docs.length - 1] as QueryDocumentSnapshot<DocumentData> | undefined,\n hasMore: snapshot.docs.length > pageSize,\n }\n }\n\n async function getPost(id: string): Promise<PostDetail | null> {\n const [metaSnap, bodySnap] = await Promise.all([getDoc(postDoc(id)), getDoc(bodyDoc(id))])\n if (!metaSnap.exists()) return null\n return {\n id: metaSnap.id,\n ...metaSnap.data(),\n content: bodySnap.exists() ? ((bodySnap.data() as { content: string }).content ?? '') : '',\n } as PostDetail\n }\n\n async function createPost(input: CreatePostInput): Promise<string> {\n const baseSlug = slugify(input.title) || 'post'\n let slug = baseSlug\n let counter = 1\n while ((await getDoc(postDoc(slug))).exists()) {\n counter++\n slug = `${baseSlug}-${counter}`\n }\n\n await Promise.all([\n setDoc(postDoc(slug), {\n title: input.title,\n tags: input.tags ?? [],\n ...(input.category ? { category: input.category } : {}),\n attachments: input.attachments ?? [],\n commentCount: 0,\n authorUid: input.authorUid,\n authorName: input.authorName,\n authorPhoto: input.authorPhoto,\n moderationStatus: 'approved',\n moderationModel: input.moderationModel ?? null,\n createdAt: serverTimestamp(),\n updatedAt: serverTimestamp(),\n }),\n setDoc(bodyDoc(slug), { content: input.content }),\n ])\n\n return slug\n }\n\n async function updatePost(id: string, input: UpdatePostInput): Promise<void> {\n await Promise.all([\n updateDoc(postDoc(id), {\n title: input.title,\n tags: input.tags ?? [],\n ...(input.category ? { category: input.category } : {}),\n attachments: input.attachments ?? [],\n updatedAt: serverTimestamp(),\n }),\n setDoc(bodyDoc(id), { content: input.content }),\n ])\n }\n\n /** 댓글 서브컬렉션 → 본문 → Storage 폴더 → 메타 순으로 삭제한다. */\n async function deletePost(id: string): Promise<void> {\n const commentsSnap = await getDocs(commentsCol(id))\n // Firestore write batch는 최대 500건. 여유를 두고 450건씩 나눠 삭제한다.\n for (let offset = 0; offset < commentsSnap.docs.length; offset += 450) {\n const batch = writeBatch(db)\n commentsSnap.docs.slice(offset, offset + 450).forEach(d => batch.delete(d.ref))\n await batch.commit()\n }\n\n const storage = getStorage(app)\n // Firestore/Storage 규칙이 부모 게시글의 소유권을 조회하므로 부모 문서는 마지막에 삭제한다.\n await deleteDoc(bodyDoc(id))\n await deleteStorageFolder(storageRef(storage, `${prefix}/posts/${id}`))\n await deleteDoc(postDoc(id))\n }\n\n return { getPosts, getPost, createPost, updatePost, deletePost }\n}\n","import { computed } from 'vue'\nimport type { Ref } from 'vue'\nimport { useCollection, useFirestore } from 'vuefire'\nimport { collection, doc, orderBy, query, serverTimestamp, writeBatch, increment } from 'firebase/firestore'\nimport { useMemiBoardConfig } from '../config'\nimport type { CommentModel } from '../types'\n\nexport interface AddCommentInput {\n body: string\n authorUid: string\n authorName: string | null\n authorPhoto: string | null\n}\n\n/** postId 서브컬렉션 댓글을 실시간(onSnapshot)으로 구독하고, 작성/삭제 시 부모 글의 commentCount를 같은 batch로 증감한다. */\nexport function useMemiBoardComments(postId: string | Ref<string>) {\n const config = useMemiBoardConfig()\n const db = useFirestore()\n const prefix = config.collectionPrefix\n\n const id = computed(() => (typeof postId === 'string' ? postId : postId.value))\n\n const commentsQuery = computed(() =>\n query(collection(db, `${prefix}Posts`, id.value, 'comments'), orderBy('createdAt', 'asc')),\n )\n\n const comments = useCollection<CommentModel>(commentsQuery)\n\n async function addComment(input: AddCommentInput): Promise<void> {\n const batch = writeBatch(db)\n const commentRef = doc(collection(db, `${prefix}Posts`, id.value, 'comments'))\n batch.set(commentRef, {\n postId: id.value,\n body: input.body,\n authorUid: input.authorUid,\n authorName: input.authorName,\n authorPhoto: input.authorPhoto,\n moderationStatus: 'approved',\n createdAt: serverTimestamp(),\n })\n batch.update(doc(db, `${prefix}Posts`, id.value), { commentCount: increment(1) })\n await batch.commit()\n }\n\n async function deleteComment(commentId: string): Promise<void> {\n const batch = writeBatch(db)\n batch.delete(doc(db, `${prefix}Posts`, id.value, 'comments', commentId))\n batch.update(doc(db, `${prefix}Posts`, id.value), { commentCount: increment(-1) })\n await batch.commit()\n }\n\n return { comments, addComment, deleteComment }\n}\n","export const MODERATION_SYSTEM = `당신은 게시판 콘텐츠를 심사하는 엄격한 모더레이터입니다.\n아래 텍스트에 욕설·비속어·성희롱·혐오·위협·스팸·도배성 광고가 있으면 반드시 거부(flagged=true)합니다.\n\nflagged=true 예:\n- 욕설/비속어: 씨발, 시발, 병신, 지랄, 좆, 개새끼, 존나, fuck, shit, bitch 등\n- 성적 모욕, 혐오 발언, 협박\n- 게시판 주제와 무관한 도배성 광고/스팸\n\nflagged=false:\n- 게시판 주제에 맞는 정상적인 글/댓글 (강한 의견이나 정당한 비판은 허용)\n\nJSON만 출력:\n{\"flagged\":boolean,\"category\":\"none\"|\"abuse\"|\"spam\"|\"adult\"|\"violence\"|\"other\",\"reason\":string}\n- flagged=true면 category는 상황에 맞게 고르고, reason은 한국어 1문장 (예: \"욕설이 포함되어 있어 게시할 수 없습니다.\")\n- flagged=false면 category는 \"none\", reason은 \"\"`\n\n/** API 없이도 1차로 걸러내는 한국어/영어 비속어 (부분 문자열 매칭) */\nexport const DEFAULT_LOCAL_BLOCKLIST = [\n '씨발', '시발', '씨팔', '시팔', '병신', '븅신', '지랄', '좆', '존나',\n '개새끼', '개새', '꺼져', '닥쳐', '씹', '니미', '느금', '애미', '애비',\n 'fuck', 'shit', 'bitch', 'asshole',\n]\n\nexport function buildLocalBlockRegex(patterns: string[]): RegExp {\n return new RegExp(patterns.join('|'), 'i')\n}\n\nconst CATEGORIES = new Set(['none', 'abuse', 'spam', 'adult', 'violence', 'other'])\n\nexport function parseModerationJson(raw: string): { flagged: boolean, category: string, reason: string } | null {\n const trimmed = raw.trim()\n if (!trimmed) return null\n\n let parsed: unknown\n try {\n parsed = JSON.parse(trimmed)\n }\n catch {\n const match = trimmed.match(/\\{[\\s\\S]*\\}/)\n if (!match) return null\n try {\n parsed = JSON.parse(match[0])\n }\n catch {\n return null\n }\n }\n\n if (typeof parsed !== 'object' || parsed === null) return null\n const obj = parsed as Record<string, unknown>\n if (typeof obj.flagged !== 'boolean') return null\n\n const category = typeof obj.category === 'string' && CATEGORIES.has(obj.category) ? obj.category : (obj.flagged ? 'other' : 'none')\n const reason = typeof obj.reason === 'string' ? obj.reason : ''\n return { flagged: obj.flagged, category, reason }\n}\n","import { getAI, getGenerativeModel, GoogleAIBackend, Schema } from 'firebase/ai'\nimport { useFirebaseApp } from 'vuefire'\nimport { buildLocalBlockRegex, DEFAULT_LOCAL_BLOCKLIST, MODERATION_SYSTEM, parseModerationJson } from '../utils/moderation-prompt'\nimport { useMemiBoardConfig } from '../config'\nimport type { ModerationResult } from '../types'\n\nconst APPROVED: ModerationResult = { flagged: false, category: 'none', reason: '' }\n\n/**\n * 글쓰기 전 블로킹 검열. 로컬 비속어 필터를 먼저 돌리고, 통과하면 Firebase AI Logic(Gemini)으로 재검사한다.\n * 검열 API 장애 시 동작은 moderation.onError 옵션을 따른다 (기본 'allow' — 검열 장애로 정상 글쓰기가 막히지 않도록).\n *\n * 알려진 한계: 이 검열은 클라이언트에서만 실행되므로, Firestore에 직접 쓰기하면 우회할 수 있다.\n * (서버가 없는 아키텍처의 근본적 한계 — README의 Security Model 참고)\n */\nexport function useMemiBoardModeration() {\n const config = useMemiBoardConfig()\n const moderation = config.moderation ?? {}\n const app = useFirebaseApp()\n\n const localBlockRe = buildLocalBlockRegex([...DEFAULT_LOCAL_BLOCKLIST, ...(moderation.localBlocklist ?? [])])\n\n function localCheck(text: string): ModerationResult | null {\n if (localBlockRe.test(text)) {\n return { flagged: true, category: 'abuse', reason: '욕설·비속어가 포함되어 게시할 수 없습니다. 표현을 바꿔 주세요.' }\n }\n return null\n }\n\n function getModerationModel() {\n const ai = getAI(app, { backend: new GoogleAIBackend(), useLimitedUseAppCheckTokens: true })\n return getGenerativeModel(ai, {\n model: moderation.model ?? 'gemini-2.5-flash',\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 }\n }\n return { ...APPROVED, error: true }\n }\n\n async function checkText(text: string): Promise<ModerationResult> {\n const trimmed = text.trim()\n if (!trimmed) return APPROVED\n\n const local = localCheck(trimmed)\n if (local) return local\n\n if (moderation.enabled === false) return APPROVED\n\n try {\n const model = getModerationModel()\n const result = await model.generateContent(`심사할 텍스트:\\n\"\"\"${trimmed.slice(0, 4000)}\"\"\"`)\n const parsed = parseModerationJson(result.response.text())\n if (!parsed) {\n console.warn('[memi-board] 검열 응답 파싱 실패 → onError 정책 적용')\n return errorResult()\n }\n return { flagged: parsed.flagged, category: parsed.category as ModerationResult['category'], reason: parsed.reason }\n }\n catch (e) {\n console.warn('[memi-board] AI 검열 호출 실패 → onError 정책 적용', e)\n return errorResult()\n }\n }\n\n async function checkImage(file: File): Promise<ModerationResult> {\n if (moderation.enabled === false || !moderation.moderateImages) return APPROVED\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 parsed = parseModerationJson(result.response.text())\n if (!parsed) return errorResult()\n return { flagged: parsed.flagged, category: parsed.category as ModerationResult['category'], reason: parsed.reason }\n }\n catch (e) {\n console.warn('[memi-board] 이미지 검열 호출 실패 → onError 정책 적용', e)\n return errorResult()\n }\n }\n\n return { checkText, checkImage }\n}\n","import { getStorage, ref as storageRef, uploadBytesResumable, getDownloadURL, deleteObject } from 'firebase/storage'\nimport type { UploadTask } from 'firebase/storage'\nimport { useFirebaseApp } from 'vuefire'\nimport { safeFileName } from '../utils/slugify'\nimport { useMemiBoardConfig } from '../config'\nimport type { Attachment } from '../types'\n\nexport function useMemiBoardStorage() {\n const config = useMemiBoardConfig()\n const app = useFirebaseApp()\n const prefix = config.collectionPrefix\n\n /** postId는 아직 저장되지 않은 새 글이면 임시 id(예: 'new-<timestamp>')를 넘겨도 된다 — 경로 네임스페이스일 뿐이다. */\n function uploadAttachment(\n file: File,\n postId: string,\n onProgress?: (ratio: number) => void,\n ): { promise: Promise<Attachment>, cancel: () => void } {\n const storage = getStorage(app)\n const path = `${prefix}/posts/${postId}/attachments/${Date.now()}-${safeFileName(file.name)}`\n const fileRef = storageRef(storage, path)\n const task: UploadTask = uploadBytesResumable(fileRef, file, {\n contentType: file.type || 'application/octet-stream',\n contentDisposition: `attachment; filename*=UTF-8''${encodeURIComponent(file.name)}`,\n })\n\n const promise = new Promise<Attachment>((resolve, reject) => {\n task.on(\n 'state_changed',\n (snapshot) => {\n if (onProgress && snapshot.totalBytes > 0) {\n onProgress(snapshot.bytesTransferred / snapshot.totalBytes)\n }\n },\n reject,\n async () => {\n const url = await getDownloadURL(fileRef)\n resolve({ name: file.name, url, path, size: file.size, type: file.type })\n },\n )\n })\n\n return { promise, cancel: () => task.cancel() }\n }\n\n async function deleteAttachment(attachment: Pick<Attachment, 'path'>): Promise<void> {\n const storage = getStorage(app)\n await deleteObject(storageRef(storage, attachment.path)).catch(() => {})\n }\n\n return { uploadAttachment, deleteAttachment }\n}\n","import { computed, watch } from 'vue'\nimport { useFirestore, useDocument } from 'vuefire'\nimport { doc, getDoc, setDoc, serverTimestamp } from 'firebase/firestore'\nimport { useMemiBoardConfig } from '../config'\nimport { useMemiBoardAuth } from './useMemiBoardAuth'\nimport type { BoardCategory, BoardSettingsModel } from '../types'\n\n/** 카테고리가 아직 설정되지 않았을 때(설정 문서가 없을 때) 쓰는 기본값. */\nconst DEFAULT_CATEGORIES: BoardCategory[] = [\n { id: 'free', label: '자유' },\n { id: 'notice', label: '공지' },\n { id: 'question', label: '질문' },\n]\n\n/**\n * 게시판 설정({prefix}Settings/config) — 지금은 카테고리 옵션만 다룬다.\n * 문서가 없으면 기본 카테고리를 그대로 보여주고, 관리자가 게시판을 방문하는 순간\n * 그 기본값으로 문서를 한 번 만들어 둔다(이후엔 Firebase 콘솔/향후 관리 UI에서 직접 수정).\n * 일반 사용자는 절대 이 문서를 쓰지 않는다(생성 실패해도 조용히 무시 — 기본값으로 계속 동작).\n */\nexport function useMemiBoardSettings() {\n const config = useMemiBoardConfig()\n const db = useFirestore()\n const { isAdmin } = useMemiBoardAuth()\n const prefix = config.collectionPrefix\n\n const settingsRef = doc(db, `${prefix}Settings`, 'config')\n const { data: settingsDoc, pending: settingsPending } = useDocument<BoardSettingsModel>(settingsRef)\n\n const categories = computed<BoardCategory[]>(() => {\n const list = settingsDoc.value?.categories\n return list && list.length > 0 ? list : DEFAULT_CATEGORIES\n })\n\n function categoryLabel(id: string | undefined): string | undefined {\n if (!id) return undefined\n return categories.value.find(c => c.id === id)?.label ?? id\n }\n\n /** 관리자가 게시판을 방문했는데 설정 문서가 아직 없으면 기본 카테고리로 한 번 만든다. */\n watch(\n [isAdmin, settingsPending],\n async ([admin, pending]) => {\n if (!admin || pending || settingsDoc.value) return\n const snap = await getDoc(settingsRef).catch(() => null)\n if (!snap || snap.exists()) return\n await setDoc(settingsRef, {\n categories: DEFAULT_CATEGORIES,\n updatedAt: serverTimestamp(),\n }).catch(() => {\n // 권한·네트워크 문제로 실패해도 화면은 기본 카테고리로 계속 동작하므로 무시한다.\n })\n },\n { immediate: true },\n )\n\n return { categories, categoryLabel, settingsPending }\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","<script setup lang=\"ts\">\nimport { ref, onMounted } from 'vue'\nimport { RouterLink } from 'vue-router'\nimport type { DocumentData, QueryDocumentSnapshot } from 'firebase/firestore'\nimport { useMemiBoardPosts } from '../composables/useMemiBoardPosts'\nimport { useMemiBoardSettings } from '../composables/useMemiBoardSettings'\nimport type { PostModel } from '../types'\nimport { formatDate } from '../utils/formatDate'\n\nconst props = withDefaults(defineProps<{\n pageSize?: number\n /** 지정하면 각 글이 `${linkBase}/${id}`로 이동하는 링크가 된다. 지정하지 않으면 select 이벤트만 emit. */\n linkBase?: string\n}>(), {\n pageSize: 20,\n})\n\nconst emit = defineEmits<{ select: [post: PostModel] }>()\n\nconst { getPosts } = useMemiBoardPosts()\nconst { categoryLabel } = useMemiBoardSettings()\n\nconst posts = ref<PostModel[]>([])\nconst cursor = ref<QueryDocumentSnapshot<DocumentData> | undefined>(undefined)\nconst hasMore = ref(false)\nconst loading = ref(false)\nconst initialLoading = ref(true)\n\nasync function loadMore() {\n loading.value = true\n try {\n const result = await getPosts({ pageSize: props.pageSize, cursor: cursor.value })\n posts.value.push(...result.posts)\n cursor.value = result.cursor\n hasMore.value = result.hasMore\n }\n finally {\n loading.value = false\n initialLoading.value = false\n }\n}\n\nonMounted(loadMore)\n\nfunction handleClick(post: PostModel) {\n if (!props.linkBase) emit('select', post)\n}\n</script>\n\n<template>\n <div class=\"flex flex-col gap-2\">\n <template v-if=\"initialLoading\">\n <USkeleton\n v-for=\"i in 3\"\n :key=\"i\"\n class=\"h-20 w-full\"\n />\n </template>\n\n <p\n v-else-if=\"!posts.length\"\n class=\"text-sm text-muted text-center py-8\"\n >\n 아직 게시글이 없습니다.\n </p>\n\n <component\n :is=\"linkBase ? RouterLink : 'button'\"\n v-for=\"post in posts\"\n :key=\"post.id\"\n :to=\"linkBase ? `${linkBase}/${post.id}` : undefined\"\n class=\"text-left\"\n @click=\"handleClick(post)\"\n >\n <UCard class=\"hover:bg-elevated/50 transition-colors\">\n <div class=\"flex items-center justify-between gap-4\">\n <div class=\"flex items-center gap-2 min-w-0\">\n <UBadge\n v-if=\"post.category\"\n :label=\"categoryLabel(post.category)\"\n variant=\"subtle\"\n size=\"sm\"\n />\n <h3 class=\"font-medium truncate\">\n {{ post.title }}\n </h3>\n </div>\n <span class=\"text-xs text-muted shrink-0\">{{ formatDate(post.createdAt) }}</span>\n </div>\n <div class=\"flex items-center gap-3 text-xs text-muted mt-2\">\n <span>{{ post.authorName ?? '익명' }}</span>\n <span class=\"flex items-center gap-1\">\n <UIcon\n name=\"i-lucide-message-circle\"\n class=\"size-3\"\n />{{ post.commentCount }}\n </span>\n <span\n v-if=\"post.attachments?.length\"\n class=\"flex items-center gap-1\"\n >\n <UIcon\n name=\"i-lucide-paperclip\"\n class=\"size-3\"\n />{{ post.attachments.length }}\n </span>\n </div>\n </UCard>\n </component>\n\n <UButton\n v-if=\"hasMore\"\n variant=\"outline\"\n color=\"neutral\"\n label=\"더 보기\"\n block\n :loading=\"loading\"\n @click=\"loadMore\"\n />\n </div>\n</template>\n","<script setup lang=\"ts\">\nimport { ref, onMounted } from 'vue'\nimport { RouterLink } from 'vue-router'\nimport type { DocumentData, QueryDocumentSnapshot } from 'firebase/firestore'\nimport { useMemiBoardPosts } from '../composables/useMemiBoardPosts'\nimport { useMemiBoardSettings } from '../composables/useMemiBoardSettings'\nimport type { PostModel } from '../types'\nimport { formatDate } from '../utils/formatDate'\n\nconst props = withDefaults(defineProps<{\n pageSize?: number\n /** 지정하면 각 글이 `${linkBase}/${id}`로 이동하는 링크가 된다. 지정하지 않으면 select 이벤트만 emit. */\n linkBase?: string\n}>(), {\n pageSize: 20,\n})\n\nconst emit = defineEmits<{ select: [post: PostModel] }>()\n\nconst { getPosts } = useMemiBoardPosts()\nconst { categoryLabel } = useMemiBoardSettings()\n\nconst posts = ref<PostModel[]>([])\nconst cursor = ref<QueryDocumentSnapshot<DocumentData> | undefined>(undefined)\nconst hasMore = ref(false)\nconst loading = ref(false)\nconst initialLoading = ref(true)\n\nasync function loadMore() {\n loading.value = true\n try {\n const result = await getPosts({ pageSize: props.pageSize, cursor: cursor.value })\n posts.value.push(...result.posts)\n cursor.value = result.cursor\n hasMore.value = result.hasMore\n }\n finally {\n loading.value = false\n initialLoading.value = false\n }\n}\n\nonMounted(loadMore)\n\nfunction handleClick(post: PostModel) {\n if (!props.linkBase) emit('select', post)\n}\n</script>\n\n<template>\n <div class=\"flex flex-col gap-2\">\n <template v-if=\"initialLoading\">\n <USkeleton\n v-for=\"i in 3\"\n :key=\"i\"\n class=\"h-20 w-full\"\n />\n </template>\n\n <p\n v-else-if=\"!posts.length\"\n class=\"text-sm text-muted text-center py-8\"\n >\n 아직 게시글이 없습니다.\n </p>\n\n <component\n :is=\"linkBase ? RouterLink : 'button'\"\n v-for=\"post in posts\"\n :key=\"post.id\"\n :to=\"linkBase ? `${linkBase}/${post.id}` : undefined\"\n class=\"text-left\"\n @click=\"handleClick(post)\"\n >\n <UCard class=\"hover:bg-elevated/50 transition-colors\">\n <div class=\"flex items-center justify-between gap-4\">\n <div class=\"flex items-center gap-2 min-w-0\">\n <UBadge\n v-if=\"post.category\"\n :label=\"categoryLabel(post.category)\"\n variant=\"subtle\"\n size=\"sm\"\n />\n <h3 class=\"font-medium truncate\">\n {{ post.title }}\n </h3>\n </div>\n <span class=\"text-xs text-muted shrink-0\">{{ formatDate(post.createdAt) }}</span>\n </div>\n <div class=\"flex items-center gap-3 text-xs text-muted mt-2\">\n <span>{{ post.authorName ?? '익명' }}</span>\n <span class=\"flex items-center gap-1\">\n <UIcon\n name=\"i-lucide-message-circle\"\n class=\"size-3\"\n />{{ post.commentCount }}\n </span>\n <span\n v-if=\"post.attachments?.length\"\n class=\"flex items-center gap-1\"\n >\n <UIcon\n name=\"i-lucide-paperclip\"\n class=\"size-3\"\n />{{ post.attachments.length }}\n </span>\n </div>\n </UCard>\n </component>\n\n <UButton\n v-if=\"hasMore\"\n variant=\"outline\"\n color=\"neutral\"\n label=\"더 보기\"\n block\n :loading=\"loading\"\n @click=\"loadMore\"\n />\n </div>\n</template>\n","<script setup lang=\"ts\">\nimport { ref } from 'vue'\nimport type { Attachment } from '../types'\nimport { useMemiBoardStorage } from '../composables/useMemiBoardStorage'\n\nconst props = withDefaults(defineProps<{\n modelValue: Attachment[]\n /** 새 글 작성 중이면 아직 slug가 없으므로 임시 네임스페이스를 넘긴다. */\n postId: string\n editable?: boolean\n maxFiles?: number\n}>(), {\n editable: false,\n maxFiles: 5,\n})\n\nconst emit = defineEmits<{ 'update:modelValue': [attachments: Attachment[]] }>()\n\nconst { uploadAttachment, deleteAttachment } = useMemiBoardStorage()\n\nconst fileInput = ref<HTMLInputElement | null>(null)\nconst uploading = ref(false)\nconst uploadProgress = ref(0)\nconst deletingIndex = ref<number | null>(null)\nconst uploadError = ref('')\n\nfunction isImage(type: string) {\n return type.startsWith('image/')\n}\n\nasync function onFileInputChange(e: Event) {\n const input = e.target as HTMLInputElement\n const files = Array.from(input.files ?? [])\n input.value = ''\n if (!files.length) return\n\n const remaining = props.maxFiles - props.modelValue.length\n const toUpload = files.slice(0, remaining)\n\n uploading.value = true\n uploadError.value = ''\n try {\n const uploaded: Attachment[] = []\n for (const file of toUpload) {\n uploadProgress.value = 0\n const { promise } = uploadAttachment(file, props.postId, (ratio) => {\n uploadProgress.value = ratio\n })\n uploaded.push(await promise)\n }\n emit('update:modelValue', [...props.modelValue, ...uploaded])\n }\n catch (e) {\n uploadError.value = (e as Error).message\n }\n finally {\n uploading.value = false\n }\n}\n\nasync function removeAttachment(index: number) {\n const attachment = props.modelValue[index]\n if (!attachment) return\n deletingIndex.value = index\n try {\n await deleteAttachment(attachment)\n emit('update:modelValue', props.modelValue.filter((_, i) => i !== index))\n }\n finally {\n deletingIndex.value = null\n }\n}\n\nfunction fileIcon(type: string): string {\n if (type === 'application/pdf') return 'i-lucide-file-text'\n if (type.includes('zip') || type.includes('archive') || type.includes('compressed')) return 'i-lucide-archive'\n if (type.includes('spreadsheet') || type.includes('excel') || type.includes('csv')) return 'i-lucide-table'\n if (type.includes('presentation') || type.includes('powerpoint')) return 'i-lucide-presentation'\n if (isImage(type)) return 'i-lucide-image'\n if (type.includes('video')) return 'i-lucide-video'\n if (type.includes('audio')) return 'i-lucide-music'\n return 'i-lucide-file'\n}\n\nfunction formatSize(bytes: number): string {\n if (bytes < 1024) return `${bytes} B`\n if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`\n return `${(bytes / 1024 / 1024).toFixed(1)} MB`\n}\n</script>\n\n<template>\n <div class=\"space-y-2\">\n <div\n v-for=\"(attachment, i) in modelValue\"\n :key=\"attachment.path\"\n class=\"flex items-center gap-3 px-3 py-2 rounded-lg border border-default\"\n >\n <img\n v-if=\"isImage(attachment.type)\"\n :src=\"attachment.url\"\n class=\"size-10 rounded object-cover shrink-0\"\n >\n <UIcon\n v-else\n :name=\"fileIcon(attachment.type)\"\n class=\"size-5 shrink-0 text-muted\"\n />\n <div class=\"flex-1 min-w-0\">\n <p class=\"text-sm font-medium truncate\">\n {{ attachment.name }}\n </p>\n <p class=\"text-xs text-muted\">\n {{ formatSize(attachment.size) }}\n </p>\n </div>\n\n <UButton\n v-if=\"editable\"\n icon=\"i-lucide-x\"\n size=\"xs\"\n color=\"error\"\n variant=\"ghost\"\n :loading=\"deletingIndex === i\"\n @click=\"removeAttachment(i)\"\n />\n <UButton\n v-else\n icon=\"i-lucide-download\"\n size=\"xs\"\n color=\"neutral\"\n variant=\"ghost\"\n :to=\"attachment.url\"\n target=\"_blank\"\n />\n </div>\n\n <template v-if=\"editable && modelValue.length < maxFiles\">\n <input\n ref=\"fileInput\"\n type=\"file\"\n multiple\n class=\"hidden\"\n @change=\"onFileInputChange\"\n >\n <UButton\n icon=\"i-lucide-paperclip\"\n :label=\"`파일 첨부 (${modelValue.length} / ${maxFiles})`\"\n variant=\"outline\"\n color=\"neutral\"\n size=\"sm\"\n :loading=\"uploading\"\n @click=\"fileInput?.click()\"\n />\n <UProgress\n v-if=\"uploading\"\n :model-value=\"Math.round(uploadProgress * 100)\"\n />\n <p\n v-if=\"uploadError\"\n class=\"text-xs text-error\"\n >\n {{ uploadError }}\n </p>\n </template>\n <p\n v-else-if=\"editable\"\n class=\"text-xs text-muted\"\n >\n 최대 {{ maxFiles }}개까지 첨부할 수 있습니다.\n </p>\n </div>\n</template>\n","<script setup lang=\"ts\">\nimport { ref } from 'vue'\nimport type { Attachment } from '../types'\nimport { useMemiBoardStorage } from '../composables/useMemiBoardStorage'\n\nconst props = withDefaults(defineProps<{\n modelValue: Attachment[]\n /** 새 글 작성 중이면 아직 slug가 없으므로 임시 네임스페이스를 넘긴다. */\n postId: string\n editable?: boolean\n maxFiles?: number\n}>(), {\n editable: false,\n maxFiles: 5,\n})\n\nconst emit = defineEmits<{ 'update:modelValue': [attachments: Attachment[]] }>()\n\nconst { uploadAttachment, deleteAttachment } = useMemiBoardStorage()\n\nconst fileInput = ref<HTMLInputElement | null>(null)\nconst uploading = ref(false)\nconst uploadProgress = ref(0)\nconst deletingIndex = ref<number | null>(null)\nconst uploadError = ref('')\n\nfunction isImage(type: string) {\n return type.startsWith('image/')\n}\n\nasync function onFileInputChange(e: Event) {\n const input = e.target as HTMLInputElement\n const files = Array.from(input.files ?? [])\n input.value = ''\n if (!files.length) return\n\n const remaining = props.maxFiles - props.modelValue.length\n const toUpload = files.slice(0, remaining)\n\n uploading.value = true\n uploadError.value = ''\n try {\n const uploaded: Attachment[] = []\n for (const file of toUpload) {\n uploadProgress.value = 0\n const { promise } = uploadAttachment(file, props.postId, (ratio) => {\n uploadProgress.value = ratio\n })\n uploaded.push(await promise)\n }\n emit('update:modelValue', [...props.modelValue, ...uploaded])\n }\n catch (e) {\n uploadError.value = (e as Error).message\n }\n finally {\n uploading.value = false\n }\n}\n\nasync function removeAttachment(index: number) {\n const attachment = props.modelValue[index]\n if (!attachment) return\n deletingIndex.value = index\n try {\n await deleteAttachment(attachment)\n emit('update:modelValue', props.modelValue.filter((_, i) => i !== index))\n }\n finally {\n deletingIndex.value = null\n }\n}\n\nfunction fileIcon(type: string): string {\n if (type === 'application/pdf') return 'i-lucide-file-text'\n if (type.includes('zip') || type.includes('archive') || type.includes('compressed')) return 'i-lucide-archive'\n if (type.includes('spreadsheet') || type.includes('excel') || type.includes('csv')) return 'i-lucide-table'\n if (type.includes('presentation') || type.includes('powerpoint')) return 'i-lucide-presentation'\n if (isImage(type)) return 'i-lucide-image'\n if (type.includes('video')) return 'i-lucide-video'\n if (type.includes('audio')) return 'i-lucide-music'\n return 'i-lucide-file'\n}\n\nfunction formatSize(bytes: number): string {\n if (bytes < 1024) return `${bytes} B`\n if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`\n return `${(bytes / 1024 / 1024).toFixed(1)} MB`\n}\n</script>\n\n<template>\n <div class=\"space-y-2\">\n <div\n v-for=\"(attachment, i) in modelValue\"\n :key=\"attachment.path\"\n class=\"flex items-center gap-3 px-3 py-2 rounded-lg border border-default\"\n >\n <img\n v-if=\"isImage(attachment.type)\"\n :src=\"attachment.url\"\n class=\"size-10 rounded object-cover shrink-0\"\n >\n <UIcon\n v-else\n :name=\"fileIcon(attachment.type)\"\n class=\"size-5 shrink-0 text-muted\"\n />\n <div class=\"flex-1 min-w-0\">\n <p class=\"text-sm font-medium truncate\">\n {{ attachment.name }}\n </p>\n <p class=\"text-xs text-muted\">\n {{ formatSize(attachment.size) }}\n </p>\n </div>\n\n <UButton\n v-if=\"editable\"\n icon=\"i-lucide-x\"\n size=\"xs\"\n color=\"error\"\n variant=\"ghost\"\n :loading=\"deletingIndex === i\"\n @click=\"removeAttachment(i)\"\n />\n <UButton\n v-else\n icon=\"i-lucide-download\"\n size=\"xs\"\n color=\"neutral\"\n variant=\"ghost\"\n :to=\"attachment.url\"\n target=\"_blank\"\n />\n </div>\n\n <template v-if=\"editable && modelValue.length < maxFiles\">\n <input\n ref=\"fileInput\"\n type=\"file\"\n multiple\n class=\"hidden\"\n @change=\"onFileInputChange\"\n >\n <UButton\n icon=\"i-lucide-paperclip\"\n :label=\"`파일 첨부 (${modelValue.length} / ${maxFiles})`\"\n variant=\"outline\"\n color=\"neutral\"\n size=\"sm\"\n :loading=\"uploading\"\n @click=\"fileInput?.click()\"\n />\n <UProgress\n v-if=\"uploading\"\n :model-value=\"Math.round(uploadProgress * 100)\"\n />\n <p\n v-if=\"uploadError\"\n class=\"text-xs text-error\"\n >\n {{ uploadError }}\n </p>\n </template>\n <p\n v-else-if=\"editable\"\n class=\"text-xs text-muted\"\n >\n 최대 {{ maxFiles }}개까지 첨부할 수 있습니다.\n </p>\n </div>\n</template>\n","<script setup lang=\"ts\">\nimport { ref } from 'vue'\nimport { useMemiBoardAuth } from '../composables/useMemiBoardAuth'\nimport { useMemiBoardComments } from '../composables/useMemiBoardComments'\nimport { useMemiBoardModeration } from '../composables/useMemiBoardModeration'\n\nconst props = defineProps<{ postId: string }>()\n\nconst { user, isSignedIn } = useMemiBoardAuth()\nconst { addComment } = useMemiBoardComments(props.postId)\nconst { checkText } = useMemiBoardModeration()\n\nconst body = ref('')\nconst submitting = ref(false)\nconst error = ref('')\n\nasync function handleSubmit() {\n error.value = ''\n if (!body.value.trim()) return\n if (!user.value) {\n error.value = '로그인이 필요합니다.'\n return\n }\n\n submitting.value = true\n try {\n const moderation = await checkText(body.value)\n if (moderation.flagged) {\n error.value = moderation.reason || '작성할 수 없는 내용이 포함되어 있습니다.'\n return\n }\n await addComment({\n body: body.value,\n authorUid: user.value.uid,\n authorName: user.value.displayName,\n authorPhoto: user.value.photoURL,\n })\n body.value = ''\n }\n catch (e) {\n error.value = (e as Error).message\n }\n finally {\n submitting.value = false\n }\n}\n</script>\n\n<template>\n <form\n v-if=\"isSignedIn\"\n class=\"flex flex-col gap-2\"\n @submit.prevent=\"handleSubmit\"\n >\n <UTextarea\n v-model=\"body\"\n placeholder=\"댓글을 입력하세요\"\n :rows=\"2\"\n />\n <div class=\"flex justify-between items-center\">\n <p\n v-if=\"error\"\n class=\"text-xs text-error\"\n >\n {{ error }}\n </p>\n <div class=\"flex-1\" />\n <UButton\n type=\"submit\"\n size=\"sm\"\n label=\"댓글 작성\"\n :loading=\"submitting\"\n :disabled=\"!body.trim()\"\n />\n </div>\n </form>\n <p\n v-else\n class=\"text-sm text-muted\"\n >\n 댓글을 작성하려면 로그인이 필요합니다.\n </p>\n</template>\n","<script setup lang=\"ts\">\nimport { ref } from 'vue'\nimport { useMemiBoardAuth } from '../composables/useMemiBoardAuth'\nimport { useMemiBoardComments } from '../composables/useMemiBoardComments'\nimport { useMemiBoardModeration } from '../composables/useMemiBoardModeration'\n\nconst props = defineProps<{ postId: string }>()\n\nconst { user, isSignedIn } = useMemiBoardAuth()\nconst { addComment } = useMemiBoardComments(props.postId)\nconst { checkText } = useMemiBoardModeration()\n\nconst body = ref('')\nconst submitting = ref(false)\nconst error = ref('')\n\nasync function handleSubmit() {\n error.value = ''\n if (!body.value.trim()) return\n if (!user.value) {\n error.value = '로그인이 필요합니다.'\n return\n }\n\n submitting.value = true\n try {\n const moderation = await checkText(body.value)\n if (moderation.flagged) {\n error.value = moderation.reason || '작성할 수 없는 내용이 포함되어 있습니다.'\n return\n }\n await addComment({\n body: body.value,\n authorUid: user.value.uid,\n authorName: user.value.displayName,\n authorPhoto: user.value.photoURL,\n })\n body.value = ''\n }\n catch (e) {\n error.value = (e as Error).message\n }\n finally {\n submitting.value = false\n }\n}\n</script>\n\n<template>\n <form\n v-if=\"isSignedIn\"\n class=\"flex flex-col gap-2\"\n @submit.prevent=\"handleSubmit\"\n >\n <UTextarea\n v-model=\"body\"\n placeholder=\"댓글을 입력하세요\"\n :rows=\"2\"\n />\n <div class=\"flex justify-between items-center\">\n <p\n v-if=\"error\"\n class=\"text-xs text-error\"\n >\n {{ error }}\n </p>\n <div class=\"flex-1\" />\n <UButton\n type=\"submit\"\n size=\"sm\"\n label=\"댓글 작성\"\n :loading=\"submitting\"\n :disabled=\"!body.trim()\"\n />\n </div>\n </form>\n <p\n v-else\n class=\"text-sm text-muted\"\n >\n 댓글을 작성하려면 로그인이 필요합니다.\n </p>\n</template>\n","<script setup lang=\"ts\">\nimport { ref } from 'vue'\nimport { formatDate } from '../utils/formatDate'\nimport { useMemiBoardComments } from '../composables/useMemiBoardComments'\nimport { useMemiBoardAuth } from '../composables/useMemiBoardAuth'\n\nconst props = defineProps<{ postId: string }>()\n\nconst { comments, deleteComment } = useMemiBoardComments(props.postId)\nconst { canDeleteComment } = useMemiBoardAuth()\n\nconst deletingId = ref<string | null>(null)\n\nasync function handleDelete(commentId: string) {\n deletingId.value = commentId\n try {\n await deleteComment(commentId)\n }\n finally {\n deletingId.value = null\n }\n}\n</script>\n\n<template>\n <div class=\"flex flex-col gap-3\">\n <p\n v-if=\"!comments.length\"\n class=\"text-sm text-muted\"\n >\n 아직 댓글이 없습니다.\n </p>\n\n <div\n v-for=\"comment in comments\"\n :key=\"comment.id\"\n class=\"flex items-start gap-3\"\n >\n <UAvatar\n :src=\"comment.authorPhoto ?? undefined\"\n :alt=\"comment.authorName ?? '익명'\"\n size=\"sm\"\n />\n <div class=\"flex-1 min-w-0\">\n <div class=\"flex items-center gap-2\">\n <span class=\"text-sm font-medium\">{{ comment.authorName ?? '익명' }}</span>\n <span class=\"text-xs text-muted\">{{ formatDate(comment.createdAt) }}</span>\n </div>\n <p class=\"text-sm whitespace-pre-wrap break-words\">\n {{ comment.body }}\n </p>\n </div>\n <UButton\n v-if=\"canDeleteComment(comment)\"\n icon=\"i-lucide-trash-2\"\n size=\"xs\"\n variant=\"ghost\"\n color=\"error\"\n :loading=\"deletingId === comment.id\"\n @click=\"handleDelete(comment.id!)\"\n />\n </div>\n </div>\n</template>\n","<script setup lang=\"ts\">\nimport { ref } from 'vue'\nimport { formatDate } from '../utils/formatDate'\nimport { useMemiBoardComments } from '../composables/useMemiBoardComments'\nimport { useMemiBoardAuth } from '../composables/useMemiBoardAuth'\n\nconst props = defineProps<{ postId: string }>()\n\nconst { comments, deleteComment } = useMemiBoardComments(props.postId)\nconst { canDeleteComment } = useMemiBoardAuth()\n\nconst deletingId = ref<string | null>(null)\n\nasync function handleDelete(commentId: string) {\n deletingId.value = commentId\n try {\n await deleteComment(commentId)\n }\n finally {\n deletingId.value = null\n }\n}\n</script>\n\n<template>\n <div class=\"flex flex-col gap-3\">\n <p\n v-if=\"!comments.length\"\n class=\"text-sm text-muted\"\n >\n 아직 댓글이 없습니다.\n </p>\n\n <div\n v-for=\"comment in comments\"\n :key=\"comment.id\"\n class=\"flex items-start gap-3\"\n >\n <UAvatar\n :src=\"comment.authorPhoto ?? undefined\"\n :alt=\"comment.authorName ?? '익명'\"\n size=\"sm\"\n />\n <div class=\"flex-1 min-w-0\">\n <div class=\"flex items-center gap-2\">\n <span class=\"text-sm font-medium\">{{ comment.authorName ?? '익명' }}</span>\n <span class=\"text-xs text-muted\">{{ formatDate(comment.createdAt) }}</span>\n </div>\n <p class=\"text-sm whitespace-pre-wrap break-words\">\n {{ comment.body }}\n </p>\n </div>\n <UButton\n v-if=\"canDeleteComment(comment)\"\n icon=\"i-lucide-trash-2\"\n size=\"xs\"\n variant=\"ghost\"\n color=\"error\"\n :loading=\"deletingId === comment.id\"\n @click=\"handleDelete(comment.id!)\"\n />\n </div>\n </div>\n</template>\n","<script setup lang=\"ts\">\nimport { ref, onMounted, watch } from 'vue'\nimport type { PostDetail } from '../types'\nimport { formatDate } from '../utils/formatDate'\nimport { useMemiBoardPosts } from '../composables/useMemiBoardPosts'\nimport { useMemiBoardAuth } from '../composables/useMemiBoardAuth'\nimport { useMemiBoardSettings } from '../composables/useMemiBoardSettings'\nimport { useMemiBoardComments } from '../composables/useMemiBoardComments'\nimport MemiBoardAttachments from './Attachments.vue'\nimport MemiBoardCommentForm from './CommentForm.vue'\nimport MemiBoardCommentList from './CommentList.vue'\n\nconst props = defineProps<{ postId: string }>()\n\nconst emit = defineEmits<{ deleted: [], edit: [postId: string] }>()\n\nconst { getPost, deletePost } = useMemiBoardPosts()\nconst { canEdit, canDelete } = useMemiBoardAuth()\nconst { categoryLabel } = useMemiBoardSettings()\n// post.commentCount는 상세 진입 시점의 스냅샷이라 새 댓글이 즉시 반영되지 않는다 — 실시간 댓글 목록 길이로 표시한다.\nconst { comments } = useMemiBoardComments(props.postId)\n\nconst post = ref<PostDetail | null>(null)\nconst loading = ref(true)\nconst deleting = ref(false)\nconst notFound = ref(false)\n\nasync function load() {\n loading.value = true\n notFound.value = false\n const result = await getPost(props.postId)\n if (!result) {\n notFound.value = true\n }\n post.value = result\n loading.value = false\n}\n\nonMounted(load)\nwatch(() => props.postId, load)\n\nasync function handleDelete() {\n if (!post.value) return\n if (!window.confirm('이 게시글을 삭제하시겠습니까? 되돌릴 수 없습니다.')) return\n deleting.value = true\n try {\n await deletePost(props.postId)\n emit('deleted')\n }\n finally {\n deleting.value = false\n }\n}\n</script>\n\n<template>\n <div\n v-if=\"loading\"\n class=\"flex flex-col gap-3\"\n >\n <USkeleton class=\"h-8 w-2/3\" />\n <USkeleton class=\"h-32 w-full\" />\n </div>\n\n <p\n v-else-if=\"notFound\"\n class=\"text-sm text-muted\"\n >\n 게시글을 찾을 수 없습니다.\n </p>\n\n <div\n v-else-if=\"post\"\n class=\"flex flex-col gap-6\"\n >\n <header class=\"flex flex-col gap-2\">\n <div class=\"flex items-start justify-between gap-4\">\n <div class=\"flex items-center gap-2 min-w-0\">\n <UBadge\n v-if=\"post.category\"\n :label=\"categoryLabel(post.category)\"\n variant=\"subtle\"\n />\n <h1 class=\"text-2xl font-semibold\">\n {{ post.title }}\n </h1>\n </div>\n <div\n v-if=\"canEdit(post) || canDelete(post)\"\n class=\"flex gap-2 shrink-0\"\n >\n <UButton\n v-if=\"canEdit(post)\"\n icon=\"i-lucide-pencil\"\n size=\"sm\"\n variant=\"outline\"\n color=\"neutral\"\n label=\"수정\"\n @click=\"emit('edit', postId)\"\n />\n <UButton\n v-if=\"canDelete(post)\"\n icon=\"i-lucide-trash-2\"\n size=\"sm\"\n variant=\"outline\"\n color=\"error\"\n label=\"삭제\"\n :loading=\"deleting\"\n @click=\"handleDelete\"\n />\n </div>\n </div>\n <div class=\"flex items-center gap-3 text-sm text-muted\">\n <UAvatar\n :src=\"post.authorPhoto ?? undefined\"\n :alt=\"post.authorName ?? '익명'\"\n size=\"xs\"\n />\n <span>{{ post.authorName ?? '익명' }}</span>\n <span>{{ formatDate(post.createdAt) }}</span>\n </div>\n <div\n v-if=\"post.tags?.length\"\n class=\"flex gap-1\"\n >\n <UBadge\n v-for=\"tag in post.tags\"\n :key=\"tag\"\n variant=\"subtle\"\n color=\"neutral\"\n size=\"sm\"\n >\n {{ tag }}\n </UBadge>\n </div>\n </header>\n\n <p class=\"whitespace-pre-wrap break-words leading-relaxed\">\n {{ post.content }}\n </p>\n\n <MemiBoardAttachments\n v-if=\"post.attachments?.length\"\n :model-value=\"post.attachments\"\n :post-id=\"postId\"\n />\n\n <section class=\"flex flex-col gap-4 border-t border-default pt-4\">\n <h2 class=\"text-sm font-medium text-muted\">\n 댓글 {{ comments.length }}\n </h2>\n <MemiBoardCommentForm :post-id=\"postId\" />\n <MemiBoardCommentList :post-id=\"postId\" />\n </section>\n </div>\n</template>\n","<script setup lang=\"ts\">\nimport { ref, onMounted, watch } from 'vue'\nimport type { PostDetail } from '../types'\nimport { formatDate } from '../utils/formatDate'\nimport { useMemiBoardPosts } from '../composables/useMemiBoardPosts'\nimport { useMemiBoardAuth } from '../composables/useMemiBoardAuth'\nimport { useMemiBoardSettings } from '../composables/useMemiBoardSettings'\nimport { useMemiBoardComments } from '../composables/useMemiBoardComments'\nimport MemiBoardAttachments from './Attachments.vue'\nimport MemiBoardCommentForm from './CommentForm.vue'\nimport MemiBoardCommentList from './CommentList.vue'\n\nconst props = defineProps<{ postId: string }>()\n\nconst emit = defineEmits<{ deleted: [], edit: [postId: string] }>()\n\nconst { getPost, deletePost } = useMemiBoardPosts()\nconst { canEdit, canDelete } = useMemiBoardAuth()\nconst { categoryLabel } = useMemiBoardSettings()\n// post.commentCount는 상세 진입 시점의 스냅샷이라 새 댓글이 즉시 반영되지 않는다 — 실시간 댓글 목록 길이로 표시한다.\nconst { comments } = useMemiBoardComments(props.postId)\n\nconst post = ref<PostDetail | null>(null)\nconst loading = ref(true)\nconst deleting = ref(false)\nconst notFound = ref(false)\n\nasync function load() {\n loading.value = true\n notFound.value = false\n const result = await getPost(props.postId)\n if (!result) {\n notFound.value = true\n }\n post.value = result\n loading.value = false\n}\n\nonMounted(load)\nwatch(() => props.postId, load)\n\nasync function handleDelete() {\n if (!post.value) return\n if (!window.confirm('이 게시글을 삭제하시겠습니까? 되돌릴 수 없습니다.')) return\n deleting.value = true\n try {\n await deletePost(props.postId)\n emit('deleted')\n }\n finally {\n deleting.value = false\n }\n}\n</script>\n\n<template>\n <div\n v-if=\"loading\"\n class=\"flex flex-col gap-3\"\n >\n <USkeleton class=\"h-8 w-2/3\" />\n <USkeleton class=\"h-32 w-full\" />\n </div>\n\n <p\n v-else-if=\"notFound\"\n class=\"text-sm text-muted\"\n >\n 게시글을 찾을 수 없습니다.\n </p>\n\n <div\n v-else-if=\"post\"\n class=\"flex flex-col gap-6\"\n >\n <header class=\"flex flex-col gap-2\">\n <div class=\"flex items-start justify-between gap-4\">\n <div class=\"flex items-center gap-2 min-w-0\">\n <UBadge\n v-if=\"post.category\"\n :label=\"categoryLabel(post.category)\"\n variant=\"subtle\"\n />\n <h1 class=\"text-2xl font-semibold\">\n {{ post.title }}\n </h1>\n </div>\n <div\n v-if=\"canEdit(post) || canDelete(post)\"\n class=\"flex gap-2 shrink-0\"\n >\n <UButton\n v-if=\"canEdit(post)\"\n icon=\"i-lucide-pencil\"\n size=\"sm\"\n variant=\"outline\"\n color=\"neutral\"\n label=\"수정\"\n @click=\"emit('edit', postId)\"\n />\n <UButton\n v-if=\"canDelete(post)\"\n icon=\"i-lucide-trash-2\"\n size=\"sm\"\n variant=\"outline\"\n color=\"error\"\n label=\"삭제\"\n :loading=\"deleting\"\n @click=\"handleDelete\"\n />\n </div>\n </div>\n <div class=\"flex items-center gap-3 text-sm text-muted\">\n <UAvatar\n :src=\"post.authorPhoto ?? undefined\"\n :alt=\"post.authorName ?? '익명'\"\n size=\"xs\"\n />\n <span>{{ post.authorName ?? '익명' }}</span>\n <span>{{ formatDate(post.createdAt) }}</span>\n </div>\n <div\n v-if=\"post.tags?.length\"\n class=\"flex gap-1\"\n >\n <UBadge\n v-for=\"tag in post.tags\"\n :key=\"tag\"\n variant=\"subtle\"\n color=\"neutral\"\n size=\"sm\"\n >\n {{ tag }}\n </UBadge>\n </div>\n </header>\n\n <p class=\"whitespace-pre-wrap break-words leading-relaxed\">\n {{ post.content }}\n </p>\n\n <MemiBoardAttachments\n v-if=\"post.attachments?.length\"\n :model-value=\"post.attachments\"\n :post-id=\"postId\"\n />\n\n <section class=\"flex flex-col gap-4 border-t border-default pt-4\">\n <h2 class=\"text-sm font-medium text-muted\">\n 댓글 {{ comments.length }}\n </h2>\n <MemiBoardCommentForm :post-id=\"postId\" />\n <MemiBoardCommentList :post-id=\"postId\" />\n </section>\n </div>\n</template>\n","<script setup lang=\"ts\">\nimport { ref, computed, onMounted } from 'vue'\nimport type { Attachment } from '../types'\nimport { useMemiBoardAuth } from '../composables/useMemiBoardAuth'\nimport { useMemiBoardPosts } from '../composables/useMemiBoardPosts'\nimport { useMemiBoardModeration } from '../composables/useMemiBoardModeration'\nimport { useMemiBoardSettings } from '../composables/useMemiBoardSettings'\nimport MemiBoardAttachments from './Attachments.vue'\n\nconst props = defineProps<{\n /** 지정하면 수정 모드, 지정하지 않으면 새 글 작성 모드. */\n postId?: string\n}>()\n\nconst emit = defineEmits<{ saved: [id: string], cancel: [] }>()\n\nconst { user } = useMemiBoardAuth()\nconst { getPost, createPost, updatePost } = useMemiBoardPosts()\nconst { checkText } = useMemiBoardModeration()\nconst { categories } = useMemiBoardSettings()\n\nconst title = ref('')\nconst content = ref('')\nconst tagsInput = ref('')\nconst category = ref<string | undefined>(undefined)\nconst attachments = ref<Attachment[]>([])\n\nconst categoryItems = computed(() => categories.value.map(c => ({ label: c.label, value: c.id })))\n\nconst loading = ref(!!props.postId)\nconst saving = ref(false)\nconst error = ref('')\n\n/** 새 글은 아직 slug가 없으므로 첨부파일 Storage 경로용 임시 네임스페이스를 쓴다. */\nconst attachmentNamespace = ref(props.postId ?? `new-${Date.now()}`)\n\nonMounted(async () => {\n if (!props.postId) return\n try {\n const post = await getPost(props.postId)\n if (!post) {\n error.value = '게시글을 찾을 수 없습니다.'\n return\n }\n title.value = post.title\n content.value = post.content\n tagsInput.value = (post.tags ?? []).join(', ')\n category.value = post.category\n attachments.value = post.attachments ?? []\n }\n finally {\n loading.value = false\n }\n})\n\nasync function handleSubmit() {\n error.value = ''\n if (!title.value.trim() || !content.value.trim()) {\n error.value = '제목과 내용을 입력해 주세요.'\n return\n }\n if (!user.value) {\n error.value = '로그인이 필요합니다.'\n return\n }\n\n saving.value = true\n try {\n const moderation = await checkText(`${title.value}\\n${content.value}`)\n if (moderation.flagged) {\n error.value = moderation.reason || '게시할 수 없는 내용이 포함되어 있습니다.'\n return\n }\n\n const tags = tagsInput.value.split(',').map(t => t.trim()).filter(Boolean)\n\n if (props.postId) {\n await updatePost(props.postId, { title: title.value, content: content.value, tags, category: category.value, attachments: attachments.value })\n emit('saved', props.postId)\n }\n else {\n const id = await createPost({\n title: title.value,\n content: content.value,\n tags,\n category: category.value,\n attachments: attachments.value,\n authorUid: user.value.uid,\n authorName: user.value.displayName,\n authorPhoto: user.value.photoURL,\n })\n emit('saved', id)\n }\n }\n catch (e) {\n error.value = (e as Error).message\n }\n finally {\n saving.value = false\n }\n}\n</script>\n\n<template>\n <div\n v-if=\"loading\"\n class=\"flex flex-col gap-3\"\n >\n <USkeleton class=\"h-10 w-full\" />\n <USkeleton class=\"h-40 w-full\" />\n </div>\n\n <form\n v-else\n class=\"flex flex-col gap-4\"\n @submit.prevent=\"handleSubmit\"\n >\n <USelect\n v-model=\"category\"\n :items=\"categoryItems\"\n placeholder=\"카테고리 선택\"\n class=\"w-40\"\n />\n <UInput\n v-model=\"title\"\n placeholder=\"제목\"\n size=\"lg\"\n required\n />\n <UTextarea\n v-model=\"content\"\n placeholder=\"내용을 입력하세요\"\n :rows=\"12\"\n required\n />\n <UInput\n v-model=\"tagsInput\"\n placeholder=\"태그 (쉼표로 구분)\"\n />\n\n <MemiBoardAttachments\n v-model=\"attachments\"\n :post-id=\"attachmentNamespace\"\n editable\n />\n\n <p\n v-if=\"error\"\n class=\"text-sm text-error\"\n >\n {{ error }}\n </p>\n\n <div class=\"flex gap-2\">\n <UButton\n type=\"submit\"\n :loading=\"saving\"\n :label=\"postId ? '수정 완료' : '게시하기'\"\n />\n <UButton\n variant=\"ghost\"\n color=\"neutral\"\n label=\"취소\"\n @click=\"emit('cancel')\"\n />\n </div>\n </form>\n</template>\n","<script setup lang=\"ts\">\nimport { ref, computed, onMounted } from 'vue'\nimport type { Attachment } from '../types'\nimport { useMemiBoardAuth } from '../composables/useMemiBoardAuth'\nimport { useMemiBoardPosts } from '../composables/useMemiBoardPosts'\nimport { useMemiBoardModeration } from '../composables/useMemiBoardModeration'\nimport { useMemiBoardSettings } from '../composables/useMemiBoardSettings'\nimport MemiBoardAttachments from './Attachments.vue'\n\nconst props = defineProps<{\n /** 지정하면 수정 모드, 지정하지 않으면 새 글 작성 모드. */\n postId?: string\n}>()\n\nconst emit = defineEmits<{ saved: [id: string], cancel: [] }>()\n\nconst { user } = useMemiBoardAuth()\nconst { getPost, createPost, updatePost } = useMemiBoardPosts()\nconst { checkText } = useMemiBoardModeration()\nconst { categories } = useMemiBoardSettings()\n\nconst title = ref('')\nconst content = ref('')\nconst tagsInput = ref('')\nconst category = ref<string | undefined>(undefined)\nconst attachments = ref<Attachment[]>([])\n\nconst categoryItems = computed(() => categories.value.map(c => ({ label: c.label, value: c.id })))\n\nconst loading = ref(!!props.postId)\nconst saving = ref(false)\nconst error = ref('')\n\n/** 새 글은 아직 slug가 없으므로 첨부파일 Storage 경로용 임시 네임스페이스를 쓴다. */\nconst attachmentNamespace = ref(props.postId ?? `new-${Date.now()}`)\n\nonMounted(async () => {\n if (!props.postId) return\n try {\n const post = await getPost(props.postId)\n if (!post) {\n error.value = '게시글을 찾을 수 없습니다.'\n return\n }\n title.value = post.title\n content.value = post.content\n tagsInput.value = (post.tags ?? []).join(', ')\n category.value = post.category\n attachments.value = post.attachments ?? []\n }\n finally {\n loading.value = false\n }\n})\n\nasync function handleSubmit() {\n error.value = ''\n if (!title.value.trim() || !content.value.trim()) {\n error.value = '제목과 내용을 입력해 주세요.'\n return\n }\n if (!user.value) {\n error.value = '로그인이 필요합니다.'\n return\n }\n\n saving.value = true\n try {\n const moderation = await checkText(`${title.value}\\n${content.value}`)\n if (moderation.flagged) {\n error.value = moderation.reason || '게시할 수 없는 내용이 포함되어 있습니다.'\n return\n }\n\n const tags = tagsInput.value.split(',').map(t => t.trim()).filter(Boolean)\n\n if (props.postId) {\n await updatePost(props.postId, { title: title.value, content: content.value, tags, category: category.value, attachments: attachments.value })\n emit('saved', props.postId)\n }\n else {\n const id = await createPost({\n title: title.value,\n content: content.value,\n tags,\n category: category.value,\n attachments: attachments.value,\n authorUid: user.value.uid,\n authorName: user.value.displayName,\n authorPhoto: user.value.photoURL,\n })\n emit('saved', id)\n }\n }\n catch (e) {\n error.value = (e as Error).message\n }\n finally {\n saving.value = false\n }\n}\n</script>\n\n<template>\n <div\n v-if=\"loading\"\n class=\"flex flex-col gap-3\"\n >\n <USkeleton class=\"h-10 w-full\" />\n <USkeleton class=\"h-40 w-full\" />\n </div>\n\n <form\n v-else\n class=\"flex flex-col gap-4\"\n @submit.prevent=\"handleSubmit\"\n >\n <USelect\n v-model=\"category\"\n :items=\"categoryItems\"\n placeholder=\"카테고리 선택\"\n class=\"w-40\"\n />\n <UInput\n v-model=\"title\"\n placeholder=\"제목\"\n size=\"lg\"\n required\n />\n <UTextarea\n v-model=\"content\"\n placeholder=\"내용을 입력하세요\"\n :rows=\"12\"\n required\n />\n <UInput\n v-model=\"tagsInput\"\n placeholder=\"태그 (쉼표로 구분)\"\n />\n\n <MemiBoardAttachments\n v-model=\"attachments\"\n :post-id=\"attachmentNamespace\"\n editable\n />\n\n <p\n v-if=\"error\"\n class=\"text-sm text-error\"\n >\n {{ error }}\n </p>\n\n <div class=\"flex gap-2\">\n <UButton\n type=\"submit\"\n :loading=\"saving\"\n :label=\"postId ? '수정 완료' : '게시하기'\"\n />\n <UButton\n variant=\"ghost\"\n color=\"neutral\"\n label=\"취소\"\n @click=\"emit('cancel')\"\n />\n </div>\n </form>\n</template>\n","<script setup lang=\"ts\">\nimport { ref, computed } from 'vue'\nimport { useMemiBoardConfig } from '../config'\nimport { useMemiBoardAuth } from '../composables/useMemiBoardAuth'\n\nconst config = useMemiBoardConfig()\nconst providers = computed(() => config.auth?.providers ?? ['google', 'apple'])\nconst hasOAuthProvider = computed(() => providers.value.includes('google') || providers.value.includes('apple'))\n\nconst { signInWithGoogle, signInWithApple, signInWithEmail, signUpWithEmail } = useMemiBoardAuth()\n\nconst mode = ref<'signin' | 'signup'>('signin')\nconst email = ref('')\nconst password = ref('')\nconst displayName = ref('')\nconst loading = ref(false)\nconst error = ref('')\n\nasync function handleGoogle() {\n loading.value = true\n error.value = ''\n try {\n await signInWithGoogle()\n }\n catch (e) {\n error.value = (e as Error).message\n }\n finally {\n loading.value = false\n }\n}\n\nasync function handleApple() {\n loading.value = true\n error.value = ''\n try {\n await signInWithApple()\n }\n catch (e) {\n error.value = (e as Error).message\n }\n finally {\n loading.value = false\n }\n}\n\nasync function handleEmailSubmit() {\n loading.value = true\n error.value = ''\n try {\n if (mode.value === 'signin') {\n await signInWithEmail(email.value, password.value)\n }\n else {\n await signUpWithEmail(email.value, password.value, displayName.value || undefined)\n }\n }\n catch (e) {\n error.value = (e as Error).message\n }\n finally {\n loading.value = false\n }\n}\n</script>\n\n<template>\n <div class=\"flex flex-col gap-4 max-w-sm\">\n <UButton\n v-if=\"providers.includes('google')\"\n icon=\"i-simple-icons-google\"\n color=\"neutral\"\n variant=\"outline\"\n label=\"Google로 로그인\"\n block\n :loading=\"loading\"\n @click=\"handleGoogle\"\n />\n\n <UButton\n v-if=\"providers.includes('apple')\"\n icon=\"i-simple-icons-apple\"\n color=\"neutral\"\n variant=\"outline\"\n label=\"Apple로 로그인\"\n block\n :loading=\"loading\"\n @click=\"handleApple\"\n />\n\n <template v-if=\"providers.includes('emailPassword')\">\n <div\n v-if=\"hasOAuthProvider\"\n class=\"flex items-center gap-2 text-xs text-muted\"\n >\n <div class=\"flex-1 border-t border-default\" />\n 또는\n <div class=\"flex-1 border-t border-default\" />\n </div>\n\n <form\n class=\"flex flex-col gap-3\"\n @submit.prevent=\"handleEmailSubmit\"\n >\n <UInput\n v-if=\"mode === 'signup'\"\n v-model=\"displayName\"\n placeholder=\"이름\"\n />\n <UInput\n v-model=\"email\"\n type=\"email\"\n placeholder=\"이메일\"\n required\n />\n <UInput\n v-model=\"password\"\n type=\"password\"\n placeholder=\"비밀번호\"\n required\n />\n <UButton\n type=\"submit\"\n block\n :loading=\"loading\"\n :label=\"mode === 'signin' ? '로그인' : '회원가입'\"\n />\n </form>\n\n <UButton\n variant=\"link\"\n size=\"sm\"\n color=\"neutral\"\n :label=\"mode === 'signin' ? '계정이 없으신가요? 회원가입' : '이미 계정이 있으신가요? 로그인'\"\n @click=\"mode = mode === 'signin' ? 'signup' : 'signin'\"\n />\n </template>\n\n <p\n v-if=\"error\"\n class=\"text-sm text-error\"\n >\n {{ error }}\n </p>\n </div>\n</template>\n","<script setup lang=\"ts\">\nimport { ref, computed } from 'vue'\nimport { useMemiBoardConfig } from '../config'\nimport { useMemiBoardAuth } from '../composables/useMemiBoardAuth'\n\nconst config = useMemiBoardConfig()\nconst providers = computed(() => config.auth?.providers ?? ['google', 'apple'])\nconst hasOAuthProvider = computed(() => providers.value.includes('google') || providers.value.includes('apple'))\n\nconst { signInWithGoogle, signInWithApple, signInWithEmail, signUpWithEmail } = useMemiBoardAuth()\n\nconst mode = ref<'signin' | 'signup'>('signin')\nconst email = ref('')\nconst password = ref('')\nconst displayName = ref('')\nconst loading = ref(false)\nconst error = ref('')\n\nasync function handleGoogle() {\n loading.value = true\n error.value = ''\n try {\n await signInWithGoogle()\n }\n catch (e) {\n error.value = (e as Error).message\n }\n finally {\n loading.value = false\n }\n}\n\nasync function handleApple() {\n loading.value = true\n error.value = ''\n try {\n await signInWithApple()\n }\n catch (e) {\n error.value = (e as Error).message\n }\n finally {\n loading.value = false\n }\n}\n\nasync function handleEmailSubmit() {\n loading.value = true\n error.value = ''\n try {\n if (mode.value === 'signin') {\n await signInWithEmail(email.value, password.value)\n }\n else {\n await signUpWithEmail(email.value, password.value, displayName.value || undefined)\n }\n }\n catch (e) {\n error.value = (e as Error).message\n }\n finally {\n loading.value = false\n }\n}\n</script>\n\n<template>\n <div class=\"flex flex-col gap-4 max-w-sm\">\n <UButton\n v-if=\"providers.includes('google')\"\n icon=\"i-simple-icons-google\"\n color=\"neutral\"\n variant=\"outline\"\n label=\"Google로 로그인\"\n block\n :loading=\"loading\"\n @click=\"handleGoogle\"\n />\n\n <UButton\n v-if=\"providers.includes('apple')\"\n icon=\"i-simple-icons-apple\"\n color=\"neutral\"\n variant=\"outline\"\n label=\"Apple로 로그인\"\n block\n :loading=\"loading\"\n @click=\"handleApple\"\n />\n\n <template v-if=\"providers.includes('emailPassword')\">\n <div\n v-if=\"hasOAuthProvider\"\n class=\"flex items-center gap-2 text-xs text-muted\"\n >\n <div class=\"flex-1 border-t border-default\" />\n 또는\n <div class=\"flex-1 border-t border-default\" />\n </div>\n\n <form\n class=\"flex flex-col gap-3\"\n @submit.prevent=\"handleEmailSubmit\"\n >\n <UInput\n v-if=\"mode === 'signup'\"\n v-model=\"displayName\"\n placeholder=\"이름\"\n />\n <UInput\n v-model=\"email\"\n type=\"email\"\n placeholder=\"이메일\"\n required\n />\n <UInput\n v-model=\"password\"\n type=\"password\"\n placeholder=\"비밀번호\"\n required\n />\n <UButton\n type=\"submit\"\n block\n :loading=\"loading\"\n :label=\"mode === 'signin' ? '로그인' : '회원가입'\"\n />\n </form>\n\n <UButton\n variant=\"link\"\n size=\"sm\"\n color=\"neutral\"\n :label=\"mode === 'signin' ? '계정이 없으신가요? 회원가입' : '이미 계정이 있으신가요? 로그인'\"\n @click=\"mode = mode === 'signin' ? 'signup' : 'signin'\"\n />\n </template>\n\n <p\n v-if=\"error\"\n class=\"text-sm text-error\"\n >\n {{ error }}\n </p>\n </div>\n</template>\n","export interface VersionHistoryEntry {\n version: string\n date: string\n highlights: string[]\n note?: string\n}\n\n/**\n * MemiBoardVersionHistory 컴포넌트가 그대로 보여주는 데이터.\n * 코드 수준의 자세한 변경 내역은 이 레포의 CHANGELOG.md를 참고 — 새 버전을 릴리스할 때 이 배열도 함께 갱신한다.\n */\nexport const versionHistory: VersionHistoryEntry[] = [\n {\n version: '0.2.2',\n date: '2026-07-31',\n highlights: [\n '카테고리 — 글을 주제별로 분류해서 올릴 수 있어요',\n ],\n },\n {\n version: '0.2.0',\n date: '2026-07-31',\n highlights: [\n '글쓰기 / 수정 / 삭제 — 제목, 본문, 태그를 자유롭게 작성',\n '파일 첨부 — 이미지·문서를 게시글에 첨부, 이미지는 미리보기 제공',\n '댓글 — 실시간으로 댓글을 달고 지울 수 있음',\n '로그인 / 권한 — 로그인해야 글을 쓸 수 있고, 본인 글만 수정·삭제 가능(관리자는 전체 관리)',\n 'AI 자동 검열 — 욕설·부적절한 내용이 담긴 글/댓글은 AI가 자동으로 걸러 게시를 막음',\n '한 줄 설정으로 바로 시작 — pages: true 옵션 하나로 게시판 화면이 바로 만들어짐',\n ],\n note: '서버 없이 동작하는 모듈이라, 실제 보안은 Firebase 프로젝트에 규칙 파일을 등록해야 완성됩니다. README의 Security Model 안내를 꼭 따라주세요.',\n },\n {\n version: '0.1.0',\n date: '2026-07-27',\n highlights: [\n '프로젝트 최초 생성 (아직 게시판 기능은 없는 빈 Nuxt 4 프로젝트)',\n ],\n },\n]\n","<script setup lang=\"ts\">\nimport { versionHistory } from '../data/versionHistory'\n</script>\n\n<template>\n <div class=\"flex flex-col gap-6\">\n <div\n v-for=\"entry in versionHistory\"\n :key=\"entry.version\"\n class=\"flex flex-col gap-2\"\n >\n <div class=\"flex items-center gap-2\">\n <UBadge\n :label=\"`v${entry.version}`\"\n color=\"primary\"\n variant=\"subtle\"\n />\n <span class=\"text-sm text-muted\">{{ entry.date }}</span>\n </div>\n <ul class=\"list-disc list-inside text-sm leading-relaxed space-y-1\">\n <li\n v-for=\"item in entry.highlights\"\n :key=\"item\"\n >\n {{ item }}\n </li>\n </ul>\n <p\n v-if=\"entry.note\"\n class=\"text-xs text-muted\"\n >\n {{ entry.note }}\n </p>\n </div>\n </div>\n</template>\n","<script setup lang=\"ts\">\nimport { versionHistory } from '../data/versionHistory'\n</script>\n\n<template>\n <div class=\"flex flex-col gap-6\">\n <div\n v-for=\"entry in versionHistory\"\n :key=\"entry.version\"\n class=\"flex flex-col gap-2\"\n >\n <div class=\"flex items-center gap-2\">\n <UBadge\n :label=\"`v${entry.version}`\"\n color=\"primary\"\n variant=\"subtle\"\n />\n <span class=\"text-sm text-muted\">{{ entry.date }}</span>\n </div>\n <ul class=\"list-disc list-inside text-sm leading-relaxed space-y-1\">\n <li\n v-for=\"item in entry.highlights\"\n :key=\"item\"\n >\n {{ item }}\n </li>\n </ul>\n <p\n v-if=\"entry.note\"\n class=\"text-xs text-muted\"\n >\n {{ entry.note }}\n </p>\n </div>\n </div>\n</template>\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/config.ts","../src/composables/useMemiBoardAuth.ts","../src/utils/slugify.ts","../src/composables/useMemiBoardPosts.ts","../src/composables/useMemiBoardComments.ts","../src/utils/moderation-prompt.ts","../src/composables/useMemiBoardModeration.ts","../src/composables/useMemiBoardStorage.ts","../src/composables/useMemiBoardSettings.ts","../src/data/versionHistory.ts","../src/utils/formatDate.ts"],"sourcesContent":["import { reactive } from 'vue'\n\nexport interface MemiBoardAuthOptions {\n providers?: Array<'google' | 'apple' | 'emailPassword'>\n}\n\nexport interface MemiBoardModerationOptions {\n enabled?: boolean\n model?: string\n localBlocklist?: string[]\n onError?: 'allow' | 'block'\n moderateImages?: boolean\n}\n\nexport interface MemiBoardConfig {\n collectionPrefix: string\n auth: MemiBoardAuthOptions\n moderation: MemiBoardModerationOptions\n}\n\nconst config = reactive<MemiBoardConfig>({\n collectionPrefix: 'memiBoard',\n auth: { providers: ['google', 'apple'] },\n moderation: { enabled: true, model: 'gemini-2.5-flash', onError: 'allow', moderateImages: false, localBlocklist: [] },\n})\n\n/**\n * 앱 부팅 시(플러그인 등에서) 한 번 호출한다. 모든 요청/세션이 같은 값을 보므로\n * 서버 렌더링에서도 안전하다 — 사용자별/요청별로 달라지는 값이 아니다.\n */\nexport function configureMemiBoard(options: Partial<MemiBoardConfig>): void {\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 config\n}\n","import { ref, computed, watch } from 'vue'\nimport type { Ref, ComputedRef } from 'vue'\nimport { useFirestore, useFirebaseAuth, useCurrentUser } from 'vuefire'\nimport { doc, getDoc, setDoc, updateDoc, serverTimestamp } from 'firebase/firestore'\nimport {\n GoogleAuthProvider,\n OAuthProvider,\n signInWithPopup,\n signInWithEmailAndPassword,\n createUserWithEmailAndPassword,\n signOut as firebaseSignOut,\n updateProfile,\n} from 'firebase/auth'\nimport type { User } from 'firebase/auth'\nimport { useMemiBoardConfig } from '../config'\nimport type { PostModel, CommentModel } from '../types'\n\nexport interface UseMemiBoardAuthReturn {\n user: ReturnType<typeof useCurrentUser>\n isSignedIn: ComputedRef<boolean>\n isAdmin: ComputedRef<boolean>\n rolePending: Ref<boolean>\n signInWithGoogle: () => ReturnType<typeof signInWithPopup>\n signInWithApple: () => ReturnType<typeof signInWithPopup>\n signInWithEmail: (email: string, password: string) => ReturnType<typeof signInWithEmailAndPassword>\n signUpWithEmail: (email: string, password: string, displayName?: string) => ReturnType<typeof createUserWithEmailAndPassword>\n signOut: () => Promise<void>\n canEdit: (post: Pick<PostModel, 'authorUid'>) => boolean\n canDelete: (post: Pick<PostModel, 'authorUid'>) => boolean\n canDeleteComment: (comment: Pick<CommentModel, 'authorUid'>) => boolean\n}\n\n/**\n * 인증 상태 + 게시판 역할(role) 관리.\n * role은 {prefix}Users/{uid} 문서에서만 읽고, 모듈은 절대 'admin'을 쓰지 않는다\n * (관리자 승격은 호스트 프로젝트 소유자가 Firebase 콘솔에서 직접 설정).\n */\nexport function useMemiBoardAuth(): UseMemiBoardAuthReturn {\n const config = useMemiBoardConfig()\n const db = useFirestore()\n const auth = useFirebaseAuth()\n const user = useCurrentUser()\n\n const role = ref<'user' | 'admin'>('user')\n const rolePending = ref(true)\n\n function roleDocRef(uid: string) {\n return doc(db, `${config.collectionPrefix}Users`, uid)\n }\n\n /** 최초 로그인 시 역할 문서를 생성(role: 'user' 고정)하고, 이후에는 표시용 정보만 갱신한다. */\n async function ensureUserDoc(firebaseUser: User): Promise<'user' | 'admin'> {\n const ref = roleDocRef(firebaseUser.uid)\n const snap = await getDoc(ref)\n if (!snap.exists()) {\n await setDoc(ref, {\n role: 'user',\n displayName: firebaseUser.displayName,\n updatedAt: serverTimestamp(),\n })\n return 'user'\n }\n await updateDoc(ref, {\n displayName: firebaseUser.displayName,\n updatedAt: serverTimestamp(),\n })\n return (snap.data().role as 'user' | 'admin') ?? 'user'\n }\n\n watch(\n user,\n async (current) => {\n rolePending.value = true\n if (!current) {\n role.value = 'user'\n rolePending.value = false\n return\n }\n try {\n role.value = await ensureUserDoc(current)\n }\n catch {\n role.value = 'user'\n }\n finally {\n rolePending.value = false\n }\n },\n { immediate: true },\n )\n\n const isSignedIn = computed(() => !!user.value)\n const isAdmin = computed(() => role.value === 'admin')\n\n function requireAuth() {\n if (!auth) throw new Error('[memi-board] Firebase Auth가 초기화되지 않았습니다.')\n return auth\n }\n\n async function signInWithGoogle() {\n return signInWithPopup(requireAuth(), new GoogleAuthProvider())\n }\n\n async function signInWithApple() {\n const provider = new OAuthProvider('apple.com')\n provider.addScope('email')\n provider.addScope('name')\n return signInWithPopup(requireAuth(), provider)\n }\n\n async function signInWithEmail(email: string, password: string) {\n return signInWithEmailAndPassword(requireAuth(), email, password)\n }\n\n async function signUpWithEmail(email: string, password: string, displayName?: string) {\n const credential = await createUserWithEmailAndPassword(requireAuth(), email, password)\n if (displayName) await updateProfile(credential.user, { displayName })\n return credential\n }\n\n async function signOut() {\n await firebaseSignOut(requireAuth())\n }\n\n function canEdit(post: Pick<PostModel, 'authorUid'>) {\n return isSignedIn.value && (user.value?.uid === post.authorUid || isAdmin.value)\n }\n\n function canDeleteComment(comment: Pick<CommentModel, 'authorUid'>) {\n return isSignedIn.value && (user.value?.uid === comment.authorUid || isAdmin.value)\n }\n\n return {\n user,\n isSignedIn,\n isAdmin,\n rolePending,\n signInWithGoogle,\n signInWithApple,\n signInWithEmail,\n signUpWithEmail,\n signOut,\n canEdit,\n canDelete: canEdit,\n canDeleteComment,\n }\n}\n","/**\n * 제목 문자열을 URL-friendly slug로 변환\n * e.g. \"게시판 모듈 만들기!!\" → \"게시판-모듈-만들기\"\n */\nexport function slugify(text: string): string {\n return text\n .trim()\n .replace(/\\s+/g, '-') // 공백(연속 포함) → 하이픈\n .replace(/[^a-zA-Z0-9가-힣ㄱ-ㅎㅏ-ㅣ-]/g, '') // 허용 문자 외 제거\n .replace(/-+/g, '-') // 연속 하이픈 → 단일 하이픈\n .replace(/^-+|-+$/g, '') // 앞뒤 하이픈 제거\n}\n\n/** Storage 경로/파일명에 안전한 문자열로 변환 (한글은 유지) */\nexport function safeFileName(name: string, maxLength = 60): string {\n return name.replace(/[^a-zA-Z0-9가-힣ㄱ-ㅎ._-]/g, '-').slice(0, maxLength)\n}\n","import {\n collection,\n doc,\n getDoc,\n getDocs,\n setDoc,\n updateDoc,\n deleteDoc,\n query,\n orderBy,\n limit as fbLimit,\n startAfter,\n serverTimestamp,\n writeBatch,\n} from 'firebase/firestore'\nimport type { DocumentData, QueryDocumentSnapshot } from 'firebase/firestore'\nimport { getStorage, ref as storageRef, listAll, deleteObject } from 'firebase/storage'\nimport type { StorageReference } from 'firebase/storage'\nimport { useFirebaseApp, useFirestore } from 'vuefire'\nimport { slugify } from '../utils/slugify'\nimport { useMemiBoardConfig } from '../config'\nimport type { Attachment, PostDetail, PostModel } from '../types'\n\nasync function deleteStorageFolder(folderRef: StorageReference): Promise<void> {\n const list = await listAll(folderRef).catch(() => ({ items: [], prefixes: [] }))\n await Promise.all([\n ...list.items.map(item => deleteObject(item)),\n ...list.prefixes.map(prefix => deleteStorageFolder(prefix)),\n ])\n}\n\nexport interface CreatePostInput {\n title: string\n content: string\n tags?: string[]\n category?: string\n attachments?: Attachment[]\n authorUid: string\n authorName: string | null\n authorPhoto: string | null\n moderationModel?: string\n}\n\nexport interface UpdatePostInput {\n title: string\n content: string\n tags?: string[]\n category?: string\n attachments?: Attachment[]\n}\n\n/** 목록 조회는 posts/{id} 메타만 읽고, 본문(body/main)은 상세 조회 시에만 읽는다. */\nexport function useMemiBoardPosts() {\n const config = useMemiBoardConfig()\n const db = useFirestore()\n const app = useFirebaseApp()\n const prefix = config.collectionPrefix\n\n const postsCol = () => collection(db, `${prefix}Posts`)\n const postDoc = (id: string) => doc(db, `${prefix}Posts`, id)\n const bodyDoc = (id: string) => doc(db, `${prefix}Posts`, id, 'body', 'main')\n const commentsCol = (id: string) => collection(db, `${prefix}Posts`, id, 'comments')\n\n async function getPosts(opts: { pageSize?: number, cursor?: QueryDocumentSnapshot<DocumentData> } = {}) {\n const pageSize = opts.pageSize ?? 20\n const constraints = opts.cursor\n ? [orderBy('createdAt', 'desc'), startAfter(opts.cursor), fbLimit(pageSize + 1)]\n : [orderBy('createdAt', 'desc'), fbLimit(pageSize + 1)]\n const snapshot = await getDocs(query(postsCol(), ...constraints))\n const docs = snapshot.docs.slice(0, pageSize)\n return {\n posts: docs.map(d => ({ id: d.id, ...d.data() }) as PostModel),\n cursor: docs[docs.length - 1] as QueryDocumentSnapshot<DocumentData> | undefined,\n hasMore: snapshot.docs.length > pageSize,\n }\n }\n\n async function getPost(id: string): Promise<PostDetail | null> {\n const [metaSnap, bodySnap] = await Promise.all([getDoc(postDoc(id)), getDoc(bodyDoc(id))])\n if (!metaSnap.exists()) return null\n return {\n id: metaSnap.id,\n ...metaSnap.data(),\n content: bodySnap.exists() ? ((bodySnap.data() as { content: string }).content ?? '') : '',\n } as PostDetail\n }\n\n async function createPost(input: CreatePostInput): Promise<string> {\n const baseSlug = slugify(input.title) || 'post'\n let slug = baseSlug\n let counter = 1\n while ((await getDoc(postDoc(slug))).exists()) {\n counter++\n slug = `${baseSlug}-${counter}`\n }\n\n await Promise.all([\n setDoc(postDoc(slug), {\n title: input.title,\n tags: input.tags ?? [],\n ...(input.category ? { category: input.category } : {}),\n attachments: input.attachments ?? [],\n commentCount: 0,\n authorUid: input.authorUid,\n authorName: input.authorName,\n authorPhoto: input.authorPhoto,\n moderationStatus: 'approved',\n moderationModel: input.moderationModel ?? null,\n createdAt: serverTimestamp(),\n updatedAt: serverTimestamp(),\n }),\n setDoc(bodyDoc(slug), { content: input.content }),\n ])\n\n return slug\n }\n\n async function updatePost(id: string, input: UpdatePostInput): Promise<void> {\n await Promise.all([\n updateDoc(postDoc(id), {\n title: input.title,\n tags: input.tags ?? [],\n ...(input.category ? { category: input.category } : {}),\n attachments: input.attachments ?? [],\n updatedAt: serverTimestamp(),\n }),\n setDoc(bodyDoc(id), { content: input.content }),\n ])\n }\n\n /** 댓글 서브컬렉션 → 본문 → Storage 폴더 → 메타 순으로 삭제한다. */\n async function deletePost(id: string): Promise<void> {\n const commentsSnap = await getDocs(commentsCol(id))\n // Firestore write batch는 최대 500건. 여유를 두고 450건씩 나눠 삭제한다.\n for (let offset = 0; offset < commentsSnap.docs.length; offset += 450) {\n const batch = writeBatch(db)\n commentsSnap.docs.slice(offset, offset + 450).forEach(d => batch.delete(d.ref))\n await batch.commit()\n }\n\n const storage = getStorage(app)\n // Firestore/Storage 규칙이 부모 게시글의 소유권을 조회하므로 부모 문서는 마지막에 삭제한다.\n await deleteDoc(bodyDoc(id))\n await deleteStorageFolder(storageRef(storage, `${prefix}/posts/${id}`))\n await deleteDoc(postDoc(id))\n }\n\n return { getPosts, getPost, createPost, updatePost, deletePost }\n}\n","import { computed } from 'vue'\nimport type { Ref } from 'vue'\nimport { useCollection, useFirestore } from 'vuefire'\nimport { collection, doc, orderBy, query, serverTimestamp, writeBatch, increment } from 'firebase/firestore'\nimport { useMemiBoardConfig } from '../config'\nimport type { CommentModel } from '../types'\n\nexport interface AddCommentInput {\n body: string\n authorUid: string\n authorName: string | null\n authorPhoto: string | null\n}\n\n/** postId 서브컬렉션 댓글을 실시간(onSnapshot)으로 구독하고, 작성/삭제 시 부모 글의 commentCount를 같은 batch로 증감한다. */\nexport function useMemiBoardComments(postId: string | Ref<string>) {\n const config = useMemiBoardConfig()\n const db = useFirestore()\n const prefix = config.collectionPrefix\n\n const id = computed(() => (typeof postId === 'string' ? postId : postId.value))\n\n const commentsQuery = computed(() =>\n query(collection(db, `${prefix}Posts`, id.value, 'comments'), orderBy('createdAt', 'asc')),\n )\n\n const comments = useCollection<CommentModel>(commentsQuery)\n\n async function addComment(input: AddCommentInput): Promise<void> {\n const batch = writeBatch(db)\n const commentRef = doc(collection(db, `${prefix}Posts`, id.value, 'comments'))\n batch.set(commentRef, {\n postId: id.value,\n body: input.body,\n authorUid: input.authorUid,\n authorName: input.authorName,\n authorPhoto: input.authorPhoto,\n moderationStatus: 'approved',\n createdAt: serverTimestamp(),\n })\n batch.update(doc(db, `${prefix}Posts`, id.value), { commentCount: increment(1) })\n await batch.commit()\n }\n\n async function deleteComment(commentId: string): Promise<void> {\n const batch = writeBatch(db)\n batch.delete(doc(db, `${prefix}Posts`, id.value, 'comments', commentId))\n batch.update(doc(db, `${prefix}Posts`, id.value), { commentCount: increment(-1) })\n await batch.commit()\n }\n\n return { comments, addComment, deleteComment }\n}\n","export const MODERATION_SYSTEM = `당신은 게시판 콘텐츠를 심사하는 엄격한 모더레이터입니다.\n아래 텍스트에 욕설·비속어·성희롱·혐오·위협·스팸·도배성 광고가 있으면 반드시 거부(flagged=true)합니다.\n\nflagged=true 예:\n- 욕설/비속어: 씨발, 시발, 병신, 지랄, 좆, 개새끼, 존나, fuck, shit, bitch 등\n- 성적 모욕, 혐오 발언, 협박\n- 게시판 주제와 무관한 도배성 광고/스팸\n\nflagged=false:\n- 게시판 주제에 맞는 정상적인 글/댓글 (강한 의견이나 정당한 비판은 허용)\n\nJSON만 출력:\n{\"flagged\":boolean,\"category\":\"none\"|\"abuse\"|\"spam\"|\"adult\"|\"violence\"|\"other\",\"reason\":string}\n- flagged=true면 category는 상황에 맞게 고르고, reason은 한국어 1문장 (예: \"욕설이 포함되어 있어 게시할 수 없습니다.\")\n- flagged=false면 category는 \"none\", reason은 \"\"`\n\n/** API 없이도 1차로 걸러내는 한국어/영어 비속어 (부분 문자열 매칭) */\nexport const DEFAULT_LOCAL_BLOCKLIST = [\n '씨발', '시발', '씨팔', '시팔', '병신', '븅신', '지랄', '좆', '존나',\n '개새끼', '개새', '꺼져', '닥쳐', '씹', '니미', '느금', '애미', '애비',\n 'fuck', 'shit', 'bitch', 'asshole',\n]\n\nexport function buildLocalBlockRegex(patterns: string[]): RegExp {\n return new RegExp(patterns.join('|'), 'i')\n}\n\nconst CATEGORIES = new Set(['none', 'abuse', 'spam', 'adult', 'violence', 'other'])\n\nexport function parseModerationJson(raw: string): { flagged: boolean, category: string, reason: string } | null {\n const trimmed = raw.trim()\n if (!trimmed) return null\n\n let parsed: unknown\n try {\n parsed = JSON.parse(trimmed)\n }\n catch {\n const match = trimmed.match(/\\{[\\s\\S]*\\}/)\n if (!match) return null\n try {\n parsed = JSON.parse(match[0])\n }\n catch {\n return null\n }\n }\n\n if (typeof parsed !== 'object' || parsed === null) return null\n const obj = parsed as Record<string, unknown>\n if (typeof obj.flagged !== 'boolean') return null\n\n const category = typeof obj.category === 'string' && CATEGORIES.has(obj.category) ? obj.category : (obj.flagged ? 'other' : 'none')\n const reason = typeof obj.reason === 'string' ? obj.reason : ''\n return { flagged: obj.flagged, category, reason }\n}\n","import { getAI, getGenerativeModel, GoogleAIBackend, Schema } from 'firebase/ai'\nimport { useFirebaseApp } from 'vuefire'\nimport { buildLocalBlockRegex, DEFAULT_LOCAL_BLOCKLIST, MODERATION_SYSTEM, parseModerationJson } from '../utils/moderation-prompt'\nimport { useMemiBoardConfig } from '../config'\nimport type { ModerationResult } from '../types'\n\nconst APPROVED: ModerationResult = { flagged: false, category: 'none', reason: '' }\n\n/**\n * 글쓰기 전 블로킹 검열. 로컬 비속어 필터를 먼저 돌리고, 통과하면 Firebase AI Logic(Gemini)으로 재검사한다.\n * 검열 API 장애 시 동작은 moderation.onError 옵션을 따른다 (기본 'allow' — 검열 장애로 정상 글쓰기가 막히지 않도록).\n *\n * 알려진 한계: 이 검열은 클라이언트에서만 실행되므로, Firestore에 직접 쓰기하면 우회할 수 있다.\n * (서버가 없는 아키텍처의 근본적 한계 — README의 Security Model 참고)\n */\nexport function useMemiBoardModeration() {\n const config = useMemiBoardConfig()\n const moderation = config.moderation ?? {}\n const app = useFirebaseApp()\n\n const localBlockRe = buildLocalBlockRegex([...DEFAULT_LOCAL_BLOCKLIST, ...(moderation.localBlocklist ?? [])])\n\n function localCheck(text: string): ModerationResult | null {\n if (localBlockRe.test(text)) {\n return { flagged: true, category: 'abuse', reason: '욕설·비속어가 포함되어 게시할 수 없습니다. 표현을 바꿔 주세요.' }\n }\n return null\n }\n\n function getModerationModel() {\n const ai = getAI(app, { backend: new GoogleAIBackend(), useLimitedUseAppCheckTokens: true })\n return getGenerativeModel(ai, {\n model: moderation.model ?? 'gemini-2.5-flash',\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 }\n }\n return { ...APPROVED, error: true }\n }\n\n async function checkText(text: string): Promise<ModerationResult> {\n const trimmed = text.trim()\n if (!trimmed) return APPROVED\n\n const local = localCheck(trimmed)\n if (local) return local\n\n if (moderation.enabled === false) return APPROVED\n\n try {\n const model = getModerationModel()\n const result = await model.generateContent(`심사할 텍스트:\\n\"\"\"${trimmed.slice(0, 4000)}\"\"\"`)\n const parsed = parseModerationJson(result.response.text())\n if (!parsed) {\n console.warn('[memi-board] 검열 응답 파싱 실패 → onError 정책 적용')\n return errorResult()\n }\n return { flagged: parsed.flagged, category: parsed.category as ModerationResult['category'], reason: parsed.reason }\n }\n catch (e) {\n console.warn('[memi-board] AI 검열 호출 실패 → onError 정책 적용', e)\n return errorResult()\n }\n }\n\n async function checkImage(file: File): Promise<ModerationResult> {\n if (moderation.enabled === false || !moderation.moderateImages) return APPROVED\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 parsed = parseModerationJson(result.response.text())\n if (!parsed) return errorResult()\n return { flagged: parsed.flagged, category: parsed.category as ModerationResult['category'], reason: parsed.reason }\n }\n catch (e) {\n console.warn('[memi-board] 이미지 검열 호출 실패 → onError 정책 적용', e)\n return errorResult()\n }\n }\n\n return { checkText, checkImage }\n}\n","import { getStorage, ref as storageRef, uploadBytesResumable, getDownloadURL, deleteObject } from 'firebase/storage'\nimport type { UploadTask } from 'firebase/storage'\nimport { useFirebaseApp } from 'vuefire'\nimport { safeFileName } from '../utils/slugify'\nimport { useMemiBoardConfig } from '../config'\nimport type { Attachment } from '../types'\n\nexport function useMemiBoardStorage() {\n const config = useMemiBoardConfig()\n const app = useFirebaseApp()\n const prefix = config.collectionPrefix\n\n /** postId는 아직 저장되지 않은 새 글이면 임시 id(예: 'new-<timestamp>')를 넘겨도 된다 — 경로 네임스페이스일 뿐이다. */\n function uploadAttachment(\n file: File,\n postId: string,\n onProgress?: (ratio: number) => void,\n ): { promise: Promise<Attachment>, cancel: () => void } {\n const storage = getStorage(app)\n const path = `${prefix}/posts/${postId}/attachments/${Date.now()}-${safeFileName(file.name)}`\n const fileRef = storageRef(storage, path)\n const task: UploadTask = uploadBytesResumable(fileRef, file, {\n contentType: file.type || 'application/octet-stream',\n contentDisposition: `attachment; filename*=UTF-8''${encodeURIComponent(file.name)}`,\n })\n\n const promise = new Promise<Attachment>((resolve, reject) => {\n task.on(\n 'state_changed',\n (snapshot) => {\n if (onProgress && snapshot.totalBytes > 0) {\n onProgress(snapshot.bytesTransferred / snapshot.totalBytes)\n }\n },\n reject,\n async () => {\n const url = await getDownloadURL(fileRef)\n resolve({ name: file.name, url, path, size: file.size, type: file.type })\n },\n )\n })\n\n return { promise, cancel: () => task.cancel() }\n }\n\n async function deleteAttachment(attachment: Pick<Attachment, 'path'>): Promise<void> {\n const storage = getStorage(app)\n await deleteObject(storageRef(storage, attachment.path)).catch(() => {})\n }\n\n return { uploadAttachment, deleteAttachment }\n}\n","import { computed, watch } from 'vue'\nimport { useFirestore, useDocument } from 'vuefire'\nimport { doc, getDoc, setDoc, serverTimestamp } from 'firebase/firestore'\nimport { useMemiBoardConfig } from '../config'\nimport { useMemiBoardAuth } from './useMemiBoardAuth'\nimport type { BoardCategory, BoardSettingsModel } from '../types'\n\n/** 카테고리가 아직 설정되지 않았을 때(설정 문서가 없을 때) 쓰는 기본값. */\nconst DEFAULT_CATEGORIES: BoardCategory[] = [\n { id: 'free', label: '자유' },\n { id: 'notice', label: '공지' },\n { id: 'question', label: '질문' },\n]\n\n/**\n * 게시판 설정({prefix}Settings/config) — 지금은 카테고리 옵션만 다룬다.\n * 문서가 없으면 기본 카테고리를 그대로 보여주고, 관리자가 게시판을 방문하는 순간\n * 그 기본값으로 문서를 한 번 만들어 둔다(이후엔 Firebase 콘솔/향후 관리 UI에서 직접 수정).\n * 일반 사용자는 절대 이 문서를 쓰지 않는다(생성 실패해도 조용히 무시 — 기본값으로 계속 동작).\n */\nexport function useMemiBoardSettings() {\n const config = useMemiBoardConfig()\n const db = useFirestore()\n const { isAdmin } = useMemiBoardAuth()\n const prefix = config.collectionPrefix\n\n const settingsRef = doc(db, `${prefix}Settings`, 'config')\n const { data: settingsDoc, pending: settingsPending } = useDocument<BoardSettingsModel>(settingsRef)\n\n const categories = computed<BoardCategory[]>(() => {\n const list = settingsDoc.value?.categories\n return list && list.length > 0 ? list : DEFAULT_CATEGORIES\n })\n\n function categoryLabel(id: string | undefined): string | undefined {\n if (!id) return undefined\n return categories.value.find(c => c.id === id)?.label ?? id\n }\n\n /** 관리자가 게시판을 방문했는데 설정 문서가 아직 없으면 기본 카테고리로 한 번 만든다. */\n watch(\n [isAdmin, settingsPending],\n async ([admin, pending]) => {\n if (!admin || pending || settingsDoc.value) return\n const snap = await getDoc(settingsRef).catch(() => null)\n if (!snap || snap.exists()) return\n await setDoc(settingsRef, {\n categories: DEFAULT_CATEGORIES,\n updatedAt: serverTimestamp(),\n }).catch(() => {\n // 권한·네트워크 문제로 실패해도 화면은 기본 카테고리로 계속 동작하므로 무시한다.\n })\n },\n { immediate: true },\n )\n\n return { categories, categoryLabel, settingsPending }\n}\n","export interface VersionHistoryEntry {\n version: string\n date: string\n highlights: string[]\n note?: string\n}\n\n/**\n * MemiBoardVersionHistory 컴포넌트가 그대로 보여주는 데이터.\n * 코드 수준의 자세한 변경 내역은 이 레포의 CHANGELOG.md를 참고 — 새 버전을 릴리스할 때 이 배열도 함께 갱신한다.\n */\nexport const versionHistory: VersionHistoryEntry[] = [\n {\n version: '0.2.2',\n date: '2026-07-31',\n highlights: [\n '카테고리 — 글을 주제별로 분류해서 올릴 수 있어요',\n ],\n },\n {\n version: '0.2.0',\n date: '2026-07-31',\n highlights: [\n '글쓰기 / 수정 / 삭제 — 제목, 본문, 태그를 자유롭게 작성',\n '파일 첨부 — 이미지·문서를 게시글에 첨부, 이미지는 미리보기 제공',\n '댓글 — 실시간으로 댓글을 달고 지울 수 있음',\n '로그인 / 권한 — 로그인해야 글을 쓸 수 있고, 본인 글만 수정·삭제 가능(관리자는 전체 관리)',\n 'AI 자동 검열 — 욕설·부적절한 내용이 담긴 글/댓글은 AI가 자동으로 걸러 게시를 막음',\n '한 줄 설정으로 바로 시작 — pages: true 옵션 하나로 게시판 화면이 바로 만들어짐',\n ],\n note: '서버 없이 동작하는 모듈이라, 실제 보안은 Firebase 프로젝트에 규칙 파일을 등록해야 완성됩니다. README의 Security Model 안내를 꼭 따라주세요.',\n },\n {\n version: '0.1.0',\n date: '2026-07-27',\n highlights: [\n '프로젝트 최초 생성 (아직 게시판 기능은 없는 빈 Nuxt 4 프로젝트)',\n ],\n },\n]\n","import type { Timestamp } from 'firebase/firestore'\n\nexport function formatDate(ts: Timestamp | undefined): string {\n return ts?.toDate ? ts.toDate().toLocaleDateString('ko-KR') : ''\n}\n"],"mappings":";;;;;;;AAoBA,IAAM,IAAS,EAA0B;CACvC,kBAAkB;CAClB,MAAM,EAAE,WAAW,CAAC,UAAU,OAAO,EAAE;CACvC,YAAY;EAAE,SAAS;EAAM,OAAO;EAAoB,SAAS;EAAS,gBAAgB;EAAO,gBAAgB,CAAC;CAAE;AACtH,CAAC;AAMD,SAAgB,EAAmB,GAAyC;CAG1E,AAFI,EAAQ,qBAAkB,EAAO,mBAAmB,EAAQ,mBAC5D,EAAQ,QAAM,OAAO,OAAO,EAAO,MAAM,EAAQ,IAAI,GACrD,EAAQ,cAAY,OAAO,OAAO,EAAO,YAAY,EAAQ,UAAU;AAC7E;AAEA,SAAgB,IAAsC;CACpD,OAAO;AACT;;;ACDA,SAAgB,IAA2C;CACzD,IAAM,IAAS,EAAmB,GAC5B,IAAK,EAAa,GAClB,IAAO,EAAgB,GACvB,IAAO,EAAe,GAEtB,IAAO,EAAsB,MAAM,GACnC,IAAc,EAAI,EAAI;CAE5B,SAAS,EAAW,GAAa;EAC/B,OAAO,EAAI,GAAI,GAAG,EAAO,iBAAiB,QAAQ,CAAG;CACvD;CAGA,eAAe,EAAc,GAA+C;EAC1E,IAAM,IAAM,EAAW,EAAa,GAAG,GACjC,IAAO,MAAM,EAAO,CAAG;EAa7B,OAZK,EAAK,OAAO,KAQjB,MAAM,EAAU,GAAK;GACnB,aAAa,EAAa;GAC1B,WAAW,EAAgB;EAC7B,CAAC,GACO,EAAK,KAAK,CAAC,CAAC,QAA6B,WAX/C,MAAM,EAAO,GAAK;GAChB,MAAM;GACN,aAAa,EAAa;GAC1B,WAAW,EAAgB;EAC7B,CAAC,GACM;CAOX;CAEA,EACE,GACA,OAAO,MAAY;EAEjB,IADA,EAAY,QAAQ,IAChB,CAAC,GAAS;GAEZ,AADA,EAAK,QAAQ,QACb,EAAY,QAAQ;GACpB;EACF;EACA,IAAI;GACF,EAAK,QAAQ,MAAM,EAAc,CAAO;EAC1C,QACM;GACJ,EAAK,QAAQ;EACf,UACQ;GACN,EAAY,QAAQ;EACtB;CACF,GACA,EAAE,WAAW,GAAK,CACpB;CAEA,IAAM,IAAa,QAAe,CAAC,CAAC,EAAK,KAAK,GACxC,IAAU,QAAe,EAAK,UAAU,OAAO;CAErD,SAAS,IAAc;EACrB,IAAI,CAAC,GAAM,MAAU,MAAM,0CAA0C;EACrE,OAAO;CACT;CAEA,eAAe,IAAmB;EAChC,OAAO,EAAgB,EAAY,GAAG,IAAI,EAAmB,CAAC;CAChE;CAEA,eAAe,IAAkB;EAC/B,IAAM,IAAW,IAAI,EAAc,WAAW;EAG9C,OAFA,EAAS,SAAS,OAAO,GACzB,EAAS,SAAS,MAAM,GACjB,EAAgB,EAAY,GAAG,CAAQ;CAChD;CAEA,eAAe,EAAgB,GAAe,GAAkB;EAC9D,OAAO,EAA2B,EAAY,GAAG,GAAO,CAAQ;CAClE;CAEA,eAAe,EAAgB,GAAe,GAAkB,GAAsB;EACpF,IAAM,IAAa,MAAM,EAA+B,EAAY,GAAG,GAAO,CAAQ;EAEtF,OADI,KAAa,MAAM,GAAc,EAAW,MAAM,EAAE,eAAY,CAAC,GAC9D;CACT;CAEA,eAAe,IAAU;EACvB,MAAM,EAAgB,EAAY,CAAC;CACrC;CAEA,SAAS,EAAQ,GAAoC;EACnD,OAAO,EAAW,UAAU,EAAK,OAAO,QAAQ,EAAK,aAAa,EAAQ;CAC5E;CAEA,SAAS,EAAiB,GAA0C;EAClE,OAAO,EAAW,UAAU,EAAK,OAAO,QAAQ,EAAQ,aAAa,EAAQ;CAC/E;CAEA,OAAO;EACL;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA,SAAA;EACA;EACA,WAAW;EACX;CACF;AACF;;;AC9IA,SAAgB,EAAQ,GAAsB;CAC5C,OAAO,EACJ,KAAK,CAAC,CACN,QAAQ,QAAQ,GAAG,CAAC,CACpB,QAAQ,2BAA2B,EAAE,CAAC,CACtC,QAAQ,OAAO,GAAG,CAAC,CACnB,QAAQ,YAAY,EAAE;AAC3B;AAGA,SAAgB,EAAa,GAAc,IAAY,IAAY;CACjE,OAAO,EAAK,QAAQ,0BAA0B,GAAG,CAAC,CAAC,MAAM,GAAG,CAAS;AACvE;;;ACOA,eAAe,EAAoB,GAA4C;CAC7E,IAAM,IAAO,MAAM,EAAQ,CAAS,CAAC,CAAC,aAAa;EAAE,OAAO,CAAC;EAAG,UAAU,CAAC;CAAE,EAAE;CAC/E,MAAM,QAAQ,IAAI,CAChB,GAAG,EAAK,MAAM,KAAI,MAAQ,EAAa,CAAI,CAAC,GAC5C,GAAG,EAAK,SAAS,KAAI,MAAU,EAAoB,CAAM,CAAC,CAC5D,CAAC;AACH;AAuBA,SAAgB,IAAoB;CAClC,IAAM,IAAS,EAAmB,GAC5B,IAAK,EAAa,GAClB,IAAM,EAAe,GACrB,IAAS,EAAO,kBAEhB,UAAiB,EAAW,GAAI,GAAG,EAAO,MAAM,GAChD,KAAW,MAAe,EAAI,GAAI,GAAG,EAAO,QAAQ,CAAE,GACtD,KAAW,MAAe,EAAI,GAAI,GAAG,EAAO,QAAQ,GAAI,QAAQ,MAAM,GACtE,KAAe,MAAe,EAAW,GAAI,GAAG,EAAO,QAAQ,GAAI,UAAU;CAEnF,eAAe,EAAS,IAA4E,CAAC,GAAG;EACtG,IAAM,IAAW,EAAK,YAAY,IAC5B,IAAc,EAAK,SACrB;GAAC,EAAQ,aAAa,MAAM;GAAG,EAAW,EAAK,MAAM;GAAG,EAAQ,IAAW,CAAC;EAAC,IAC7E,CAAC,EAAQ,aAAa,MAAM,GAAG,EAAQ,IAAW,CAAC,CAAC,GAClD,IAAW,MAAM,EAAQ,EAAM,EAAS,GAAG,GAAG,CAAW,CAAC,GAC1D,IAAO,EAAS,KAAK,MAAM,GAAG,CAAQ;EAC5C,OAAO;GACL,OAAO,EAAK,KAAI,OAAM;IAAE,IAAI,EAAE;IAAI,GAAG,EAAE,KAAK;GAAE,EAAe;GAC7D,QAAQ,EAAK,EAAK,SAAS;GAC3B,SAAS,EAAS,KAAK,SAAS;EAClC;CACF;CAEA,eAAe,EAAQ,GAAwC;EAC7D,IAAM,CAAC,GAAU,KAAY,MAAM,QAAQ,IAAI,CAAC,EAAO,EAAQ,CAAE,CAAC,GAAG,EAAO,EAAQ,CAAE,CAAC,CAAC,CAAC;EAEzF,OADK,EAAS,OAAO,IACd;GACL,IAAI,EAAS;GACb,GAAG,EAAS,KAAK;GACjB,SAAS,EAAS,OAAO,IAAM,EAAS,KAAK,CAAC,CAAyB,WAAW,KAAM;EAC1F,IAL+B;CAMjC;CAEA,eAAe,EAAW,GAAyC;EACjE,IAAM,IAAW,EAAQ,EAAM,KAAK,KAAK,QACrC,IAAO,GACP,IAAU;EACd,QAAQ,MAAM,EAAO,EAAQ,CAAI,CAAC,EAAA,CAAG,OAAO,IAE1C,AADA,KACA,IAAO,GAAG,EAAS,GAAG;EAqBxB,OAlBA,MAAM,QAAQ,IAAI,CAChB,EAAO,EAAQ,CAAI,GAAG;GACpB,OAAO,EAAM;GACb,MAAM,EAAM,QAAQ,CAAC;GACrB,GAAI,EAAM,WAAW,EAAE,UAAU,EAAM,SAAS,IAAI,CAAC;GACrD,aAAa,EAAM,eAAe,CAAC;GACnC,cAAc;GACd,WAAW,EAAM;GACjB,YAAY,EAAM;GAClB,aAAa,EAAM;GACnB,kBAAkB;GAClB,iBAAiB,EAAM,mBAAmB;GAC1C,WAAW,EAAgB;GAC3B,WAAW,EAAgB;EAC7B,CAAC,GACD,EAAO,EAAQ,CAAI,GAAG,EAAE,SAAS,EAAM,QAAQ,CAAC,CAClD,CAAC,GAEM;CACT;CAEA,eAAe,EAAW,GAAY,GAAuC;EAC3E,MAAM,QAAQ,IAAI,CAChB,EAAU,EAAQ,CAAE,GAAG;GACrB,OAAO,EAAM;GACb,MAAM,EAAM,QAAQ,CAAC;GACrB,GAAI,EAAM,WAAW,EAAE,UAAU,EAAM,SAAS,IAAI,CAAC;GACrD,aAAa,EAAM,eAAe,CAAC;GACnC,WAAW,EAAgB;EAC7B,CAAC,GACD,EAAO,EAAQ,CAAE,GAAG,EAAE,SAAS,EAAM,QAAQ,CAAC,CAChD,CAAC;CACH;CAGA,eAAe,EAAW,GAA2B;EACnD,IAAM,IAAe,MAAM,EAAQ,EAAY,CAAE,CAAC;EAElD,KAAK,IAAI,IAAS,GAAG,IAAS,EAAa,KAAK,QAAQ,KAAU,KAAK;GACrE,IAAM,IAAQ,EAAW,CAAE;GAE3B,AADA,EAAa,KAAK,MAAM,GAAQ,IAAS,GAAG,CAAC,CAAC,SAAQ,MAAK,EAAM,OAAO,EAAE,GAAG,CAAC,GAC9E,MAAM,EAAM,OAAO;EACrB;EAEA,IAAM,IAAU,EAAW,CAAG;EAI9B,AAFA,MAAM,EAAU,EAAQ,CAAE,CAAC,GAC3B,MAAM,EAAoB,EAAW,GAAS,GAAG,EAAO,SAAS,GAAI,CAAC,GACtE,MAAM,EAAU,EAAQ,CAAE,CAAC;CAC7B;CAEA,OAAO;EAAE;EAAU;EAAS;EAAY;EAAY;CAAW;AACjE;;;ACrIA,SAAgB,EAAqB,GAA8B;CACjE,IAAM,IAAS,EAAmB,GAC5B,IAAK,EAAa,GAClB,IAAS,EAAO,kBAEhB,IAAK,QAAgB,OAAO,KAAW,WAAW,IAAS,EAAO,KAAM,GAExE,IAAgB,QACpB,EAAM,EAAW,GAAI,GAAG,EAAO,QAAQ,EAAG,OAAO,UAAU,GAAG,EAAQ,aAAa,KAAK,CAAC,CAC3F,GAEM,IAAW,EAA4B,CAAa;CAE1D,eAAe,EAAW,GAAuC;EAC/D,IAAM,IAAQ,EAAW,CAAE,GACrB,IAAa,EAAI,EAAW,GAAI,GAAG,EAAO,QAAQ,EAAG,OAAO,UAAU,CAAC;EAW7E,AAVA,EAAM,IAAI,GAAY;GACpB,QAAQ,EAAG;GACX,MAAM,EAAM;GACZ,WAAW,EAAM;GACjB,YAAY,EAAM;GAClB,aAAa,EAAM;GACnB,kBAAkB;GAClB,WAAW,EAAgB;EAC7B,CAAC,GACD,EAAM,OAAO,EAAI,GAAI,GAAG,EAAO,QAAQ,EAAG,KAAK,GAAG,EAAE,cAAc,EAAU,CAAC,EAAE,CAAC,GAChF,MAAM,EAAM,OAAO;CACrB;CAEA,eAAe,EAAc,GAAkC;EAC7D,IAAM,IAAQ,EAAW,CAAE;EAG3B,AAFA,EAAM,OAAO,EAAI,GAAI,GAAG,EAAO,QAAQ,EAAG,OAAO,YAAY,CAAS,CAAC,GACvE,EAAM,OAAO,EAAI,GAAI,GAAG,EAAO,QAAQ,EAAG,KAAK,GAAG,EAAE,cAAc,EAAU,EAAE,EAAE,CAAC,GACjF,MAAM,EAAM,OAAO;CACrB;CAEA,OAAO;EAAE;EAAU;EAAY;CAAc;AAC/C;;;ACpDA,IAAa,IAAoB,0iBAiBpB,IAA0B;CACrC;CAAM;CAAM;CAAM;CAAM;CAAM;CAAM;CAAM;CAAK;CAC/C;CAAO;CAAM;CAAM;CAAM;CAAK;CAAM;CAAM;CAAM;CAChD;CAAQ;CAAQ;CAAS;AAC3B;AAEA,SAAgB,GAAqB,GAA4B;CAC/D,OAAO,IAAI,OAAO,EAAS,KAAK,GAAG,GAAG,GAAG;AAC3C;AAEA,IAAM,qBAAa,IAAI,IAAI;CAAC;CAAQ;CAAS;CAAQ;CAAS;CAAY;AAAO,CAAC;AAElF,SAAgB,EAAoB,GAA4E;CAC9G,IAAM,IAAU,EAAI,KAAK;CACzB,IAAI,CAAC,GAAS,OAAO;CAErB,IAAI;CACJ,IAAI;EACF,IAAS,KAAK,MAAM,CAAO;CAC7B,QACM;EACJ,IAAM,IAAQ,EAAQ,MAAM,aAAa;EACzC,IAAI,CAAC,GAAO,OAAO;EACnB,IAAI;GACF,IAAS,KAAK,MAAM,EAAM,EAAE;EAC9B,QACM;GACJ,OAAO;EACT;CACF;CAEA,IAAI,OAAO,KAAW,aAAY,GAAiB,OAAO;CAC1D,IAAM,IAAM;CACZ,IAAI,OAAO,EAAI,WAAY,WAAW,OAAO;CAE7C,IAAM,IAAW,OAAO,EAAI,YAAa,YAAY,GAAW,IAAI,EAAI,QAAQ,IAAI,EAAI,WAAY,EAAI,UAAU,UAAU,QACtH,IAAS,OAAO,EAAI,UAAW,WAAW,EAAI,SAAS;CAC7D,OAAO;EAAE,SAAS,EAAI;EAAS;EAAU;CAAO;AAClD;;;ACjDA,IAAM,IAA6B;CAAE,SAAS;CAAO,UAAU;CAAQ,QAAQ;AAAG;AASlF,SAAgB,KAAyB;CAEvC,IAAM,IADS,EACI,CAAA,CAAO,cAAc,CAAC,GACnC,IAAM,EAAe,GAErB,IAAe,GAAqB,CAAC,GAAG,GAAyB,GAAI,EAAW,kBAAkB,CAAC,CAAE,CAAC;CAE5G,SAAS,EAAW,GAAuC;EAIzD,OAHI,EAAa,KAAK,CAAI,IACjB;GAAE,SAAS;GAAM,UAAU;GAAS,QAAQ;EAAuC,IAErF;CACT;CAEA,SAAS,IAAqB;EAC5B,IAAM,IAAK,EAAM,GAAK;GAAE,SAAS,IAAI,EAAgB;GAAG,6BAA6B;EAAK,CAAC;EAC3F,OAAO,EAAmB,GAAI;GAC5B,OAAO,EAAW,SAAS;GAC3B,mBAAmB;GACnB,kBAAkB;IAChB,aAAa;IACb,iBAAiB;IACjB,kBAAkB;IAClB,gBAAgB,EAAO,OAAO,EAC5B,YAAY;KACV,SAAS,EAAO,QAAQ;KACxB,UAAU,EAAO,OAAO;KACxB,QAAQ,EAAO,OAAO;IACxB,EACF,CAAC;GACH;EACF,CAAC;CACH;CAEA,SAAS,IAAgC;EASvC,OARI,EAAW,YAAY,UAClB;GACL,SAAS;GACT,UAAU;GACV,QAAQ;GACR,OAAO;EACT,IAEK;GAAE,GAAG;GAAU,OAAO;EAAK;CACpC;CAEA,eAAe,EAAU,GAAyC;EAChE,IAAM,IAAU,EAAK,KAAK;EAC1B,IAAI,CAAC,GAAS,OAAO;EAErB,IAAM,IAAQ,EAAW,CAAO;EAChC,IAAI,GAAO,OAAO;EAElB,IAAI,EAAW,YAAY,IAAO,OAAO;EAEzC,IAAI;GAGF,IAAM,IAAS,GAAoB,MAFrB,EACO,CAAA,CAAM,gBAAgB,gBAAgB,EAAQ,MAAM,GAAG,GAAI,EAAE,IAAI,EACnD,CAAO,SAAS,KAAK,CAAC;GAKzD,OAJK,IAIE;IAAE,SAAS,EAAO;IAAS,UAAU,EAAO;IAA0C,QAAQ,EAAO;GAAO,KAHjH,QAAQ,KAAK,0CAA0C,GAChD,EAAY;EAGvB,SACO,GAAG;GAER,OADA,QAAQ,KAAK,4CAA4C,CAAC,GACnD,EAAY;EACrB;CACF;CAEA,eAAe,EAAW,GAAuC;EAC/D,IAAI,EAAW,YAAY,MAAS,CAAC,EAAW,gBAAgB,OAAO;EAEvE,IAAI;GACF,IAAM,IAAS,MAAM,IAAI,SAAiB,GAAS,MAAW;IAC5D,IAAM,IAAS,IAAI,WAAW;IAG9B,AAFA,EAAO,eAAe,EAAS,EAAO,OAAkB,MAAM,GAAG,CAAC,CAAC,MAAM,EAAE,GAC3E,EAAO,UAAU,GACjB,EAAO,cAAc,CAAI;GAC3B,CAAC,GAOK,IAAS,GAAoB,MALrB,EACO,CAAA,CAAM,gBAAgB,CACzC,EAAE,YAAY;IAAE,UAAU,EAAK,QAAQ;IAAc,MAAM;GAAO,EAAE,GACpE,iBACF,CAAC,EACkC,CAAO,SAAS,KAAK,CAAC;GAEzD,OADK,IACE;IAAE,SAAS,EAAO;IAAS,UAAU,EAAO;IAA0C,QAAQ,EAAO;GAAO,IAD/F,EAAY;EAElC,SACO,GAAG;GAER,OADA,QAAQ,KAAK,6CAA6C,CAAC,GACpD,EAAY;EACrB;CACF;CAEA,OAAO;EAAE;EAAW;CAAW;AACjC;;;AC1GA,SAAgB,KAAsB;CACpC,IAAM,IAAS,EAAmB,GAC5B,IAAM,EAAe,GACrB,IAAS,EAAO;CAGtB,SAAS,EACP,GACA,GACA,GACsD;EACtD,IAAM,IAAU,EAAW,CAAG,GACxB,IAAO,GAAG,EAAO,SAAS,EAAO,eAAe,KAAK,IAAI,EAAE,GAAG,EAAa,EAAK,IAAI,KACpF,IAAU,EAAW,GAAS,CAAI,GAClC,IAAmB,EAAqB,GAAS,GAAM;GAC3D,aAAa,EAAK,QAAQ;GAC1B,oBAAoB,gCAAgC,mBAAmB,EAAK,IAAI;EAClF,CAAC;EAkBD,OAAO;GAAE,SAAA,IAhBW,SAAqB,GAAS,MAAW;IAC3D,EAAK,GACH,kBACC,MAAa;KACZ,AAAI,KAAc,EAAS,aAAa,KACtC,EAAW,EAAS,mBAAmB,EAAS,UAAU;IAE9D,GACA,GACA,YAAY;KACV,IAAM,IAAM,MAAM,EAAe,CAAO;KACxC,EAAQ;MAAE,MAAM,EAAK;MAAM;MAAK;MAAM,MAAM,EAAK;MAAM,MAAM,EAAK;KAAK,CAAC;IAC1E,CACF;GACF,CAES;GAAS,cAAc,EAAK,OAAO;EAAE;CAChD;CAEA,eAAe,EAAiB,GAAqD;EACnF,IAAM,IAAU,EAAW,CAAG;EAC9B,MAAM,EAAa,EAAW,GAAS,EAAW,IAAI,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC;CACzE;CAEA,OAAO;EAAE;EAAkB;CAAiB;AAC9C;;;AC3CA,IAAM,IAAsC;CAC1C;EAAE,IAAI;EAAQ,OAAO;CAAK;CAC1B;EAAE,IAAI;EAAU,OAAO;CAAK;CAC5B;EAAE,IAAI;EAAY,OAAO;CAAK;AAChC;AAQA,SAAgB,KAAuB;CACrC,IAAM,IAAS,EAAmB,GAC5B,IAAK,EAAa,GAClB,EAAE,eAAY,EAAiB,GAC/B,IAAS,EAAO,kBAEhB,IAAc,EAAI,GAAI,GAAG,EAAO,WAAW,QAAQ,GACnD,EAAE,MAAM,GAAa,SAAS,MAAoB,EAAgC,CAAW,GAE7F,IAAa,QAAgC;EACjD,IAAM,IAAO,EAAY,OAAO;EAChC,OAAO,KAAQ,EAAK,SAAS,IAAI,IAAO;CAC1C,CAAC;CAED,SAAS,EAAc,GAA4C;EAC5D,OACL,OAAO,EAAW,MAAM,MAAK,MAAK,EAAE,OAAO,CAAE,CAAC,EAAE,SAAS;CAC3D;CAmBA,OAhBA,EACE,CAAC,GAAS,CAAe,GACzB,OAAO,CAAC,GAAO,OAAa;EAC1B,IAAI,CAAC,KAAS,KAAW,EAAY,OAAO;EAC5C,IAAM,IAAO,MAAM,EAAO,CAAW,CAAC,CAAC,YAAY,IAAI;EACnD,CAAC,KAAQ,EAAK,OAAO,KACzB,MAAM,EAAO,GAAa;GACxB,YAAY;GACZ,WAAW,EAAgB;EAC7B,CAAC,CAAC,CAAC,YAAY,CAEf,CAAC;CACH,GACA,EAAE,WAAW,GAAK,CACpB,GAEO;EAAE;EAAY;EAAe;CAAgB;AACtD;;;AC9CA,IAAa,KAAwC;CACnD;EACE,SAAS;EACT,MAAM;EACN,YAAY,CACV,8BACF;CACF;CACA;EACE,SAAS;EACT,MAAM;EACN,YAAY;GACV;GACA;GACA;GACA;GACA;GACA;EACF;EACA,MAAM;CACR;CACA;EACE,SAAS;EACT,MAAM;EACN,YAAY,CACV,0CACF;CACF;AACF;;;ACrCA,SAAgB,GAAW,GAAmC;CAC5D,OAAO,GAAI,SAAS,EAAG,OAAO,CAAC,CAAC,mBAAmB,OAAO,IAAI;AAChE"}
|
package/dist/module.d.ts
ADDED
package/dist/module.mjs
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { addComponentsDir as e, addImports as t, createResolver as n, defineNuxtModule as r } from "@nuxt/kit";
|
|
2
|
+
import { existsSync as i } from "node:fs";
|
|
3
|
+
//#region src/module.ts
|
|
4
|
+
var a = r({
|
|
5
|
+
meta: {
|
|
6
|
+
name: "memi-board",
|
|
7
|
+
configKey: "memiBoard"
|
|
8
|
+
},
|
|
9
|
+
setup(r, a) {
|
|
10
|
+
let { resolve: o } = n(import.meta.url), s = o("./runtime/components"), c = o("./components"), l = i(s) ? s : c;
|
|
11
|
+
a.options.build.transpile.push("memi-board"), a.options.vite ||= {}, a.options.vite.optimizeDeps ||= {};
|
|
12
|
+
let u = new Set(a.options.vite.optimizeDeps.exclude || []);
|
|
13
|
+
u.add("memi-board"), a.options.vite.optimizeDeps.exclude = [...u], e({
|
|
14
|
+
path: l,
|
|
15
|
+
pathPrefix: !1,
|
|
16
|
+
prefix: "MemiBoard"
|
|
17
|
+
});
|
|
18
|
+
let d = "memi-board";
|
|
19
|
+
t([
|
|
20
|
+
{
|
|
21
|
+
name: "configureMemiBoard",
|
|
22
|
+
from: d
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: "useMemiBoardConfig",
|
|
26
|
+
from: d
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
name: "useMemiBoardAuth",
|
|
30
|
+
from: d
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: "useMemiBoardPosts",
|
|
34
|
+
from: d
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: "useMemiBoardComments",
|
|
38
|
+
from: d
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: "useMemiBoardModeration",
|
|
42
|
+
from: d
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: "useMemiBoardStorage",
|
|
46
|
+
from: d
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: "useMemiBoardSettings",
|
|
50
|
+
from: d
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: "versionHistory",
|
|
54
|
+
from: d
|
|
55
|
+
}
|
|
56
|
+
]);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
//#endregion
|
|
60
|
+
export { a as default };
|
|
61
|
+
|
|
62
|
+
//# sourceMappingURL=module.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"module.mjs","names":[],"sources":["../src/module.ts"],"sourcesContent":["// @ts-nocheck — NuxtModule 반환 타입이 @nuxt/schema 경로에 묶여 d.ts 생성 시 불안정\nimport {\n defineNuxtModule,\n createResolver,\n addComponentsDir,\n addImports,\n} from '@nuxt/kit'\nimport { existsSync } from 'node:fs'\n\n/**\n * Thin Nuxt module — 호스트 Vite 파이프라인에 컴포넌트 SFC 만 올린다.\n *\n * 하지 않는 일:\n * - nuxt-vuefire / Firebase 설정\n * - 라우트·미들웨어 등록\n * - configureMemiBoard (호스트 플러그인에서 호출)\n *\n * 하는 일:\n * - components 디렉터리 등록 → 호스트가 SFC 컴파일 → Nuxt UI auto-import 정상 동작\n * - composable / configure 를 auto-import\n * - transpile + optimizeDeps.exclude 로 prebundle 재발 방지\n */\nexport default defineNuxtModule({\n meta: {\n name: 'memi-board',\n configKey: 'memiBoard',\n },\n setup(_options, nuxt) {\n const { resolve } = createResolver(import.meta.url)\n\n // dist/module.mjs → dist/runtime/components\n // src/module.ts (dev) → src/components\n const runtimeComponents = resolve('./runtime/components')\n const srcComponents = resolve('./components')\n const componentsDir = existsSync(runtimeComponents)\n ? runtimeComponents\n : srcComponents\n\n nuxt.options.build.transpile.push('memi-board')\n\n nuxt.options.vite ||= {}\n nuxt.options.vite.optimizeDeps ||= {}\n const exclude = new Set(nuxt.options.vite.optimizeDeps.exclude || [])\n exclude.add('memi-board')\n nuxt.options.vite.optimizeDeps.exclude = [...exclude]\n\n addComponentsDir({\n path: componentsDir,\n pathPrefix: false,\n prefix: 'MemiBoard',\n // List.vue → MemiBoardList\n })\n\n const from = 'memi-board'\n addImports([\n { name: 'configureMemiBoard', from },\n { name: 'useMemiBoardConfig', from },\n { name: 'useMemiBoardAuth', from },\n { name: 'useMemiBoardPosts', from },\n { name: 'useMemiBoardComments', from },\n { name: 'useMemiBoardModeration', from },\n { name: 'useMemiBoardStorage', from },\n { name: 'useMemiBoardSettings', from },\n { name: 'versionHistory', from },\n ])\n },\n})\n"],"mappings":";;;AAsBA,IAAA,IAAe,EAAiB;CAC9B,MAAM;EACJ,MAAM;EACN,WAAW;CACb;CACA,MAAM,GAAU,GAAM;EACpB,IAAM,EAAE,eAAY,EAAe,YAAY,GAAG,GAI5C,IAAoB,EAAQ,sBAAsB,GAClD,IAAgB,EAAQ,cAAc,GACtC,IAAgB,EAAW,CAAiB,IAC9C,IACA;EAKJ,AAHA,EAAK,QAAQ,MAAM,UAAU,KAAK,YAAY,GAE9C,EAAK,QAAQ,SAAS,CAAC,GACvB,EAAK,QAAQ,KAAK,iBAAiB,CAAC;EACpC,IAAM,IAAU,IAAI,IAAI,EAAK,QAAQ,KAAK,aAAa,WAAW,CAAC,CAAC;EAIpE,AAHA,EAAQ,IAAI,YAAY,GACxB,EAAK,QAAQ,KAAK,aAAa,UAAU,CAAC,GAAG,CAAO,GAEpD,EAAiB;GACf,MAAM;GACN,YAAY;GACZ,QAAQ;EAEV,CAAC;EAED,IAAM,IAAO;EACb,EAAW;GACT;IAAE,MAAM;IAAsB;GAAK;GACnC;IAAE,MAAM;IAAsB;GAAK;GACnC;IAAE,MAAM;IAAoB;GAAK;GACjC;IAAE,MAAM;IAAqB;GAAK;GAClC;IAAE,MAAM;IAAwB;GAAK;GACrC;IAAE,MAAM;IAA0B;GAAK;GACvC;IAAE,MAAM;IAAuB;GAAK;GACpC;IAAE,MAAM;IAAwB;GAAK;GACrC;IAAE,MAAM;IAAkB;GAAK;EACjC,CAAC;CACH;AACF,CAAC"}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import type { Attachment } from 'memi-board'
|
|
4
|
+
import { useMemiBoardStorage } from 'memi-board'
|
|
5
|
+
|
|
6
|
+
const props = withDefaults(defineProps<{
|
|
7
|
+
modelValue: Attachment[]
|
|
8
|
+
/** 새 글 작성 중이면 아직 slug가 없으므로 임시 네임스페이스를 넘긴다. */
|
|
9
|
+
postId: string
|
|
10
|
+
editable?: boolean
|
|
11
|
+
maxFiles?: number
|
|
12
|
+
}>(), {
|
|
13
|
+
editable: false,
|
|
14
|
+
maxFiles: 5,
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
const emit = defineEmits<{ 'update:modelValue': [attachments: Attachment[]] }>()
|
|
18
|
+
|
|
19
|
+
const { uploadAttachment, deleteAttachment } = useMemiBoardStorage()
|
|
20
|
+
|
|
21
|
+
const fileInput = ref<HTMLInputElement | null>(null)
|
|
22
|
+
const uploading = ref(false)
|
|
23
|
+
const uploadProgress = ref(0)
|
|
24
|
+
const deletingIndex = ref<number | null>(null)
|
|
25
|
+
const uploadError = ref('')
|
|
26
|
+
|
|
27
|
+
function isImage(type: string) {
|
|
28
|
+
return type.startsWith('image/')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function onFileInputChange(e: Event) {
|
|
32
|
+
const input = e.target as HTMLInputElement
|
|
33
|
+
const files = Array.from(input.files ?? [])
|
|
34
|
+
input.value = ''
|
|
35
|
+
if (!files.length) return
|
|
36
|
+
|
|
37
|
+
const remaining = props.maxFiles - props.modelValue.length
|
|
38
|
+
const toUpload = files.slice(0, remaining)
|
|
39
|
+
|
|
40
|
+
uploading.value = true
|
|
41
|
+
uploadError.value = ''
|
|
42
|
+
try {
|
|
43
|
+
const uploaded: Attachment[] = []
|
|
44
|
+
for (const file of toUpload) {
|
|
45
|
+
uploadProgress.value = 0
|
|
46
|
+
const { promise } = uploadAttachment(file, props.postId, (ratio) => {
|
|
47
|
+
uploadProgress.value = ratio
|
|
48
|
+
})
|
|
49
|
+
uploaded.push(await promise)
|
|
50
|
+
}
|
|
51
|
+
emit('update:modelValue', [...props.modelValue, ...uploaded])
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
uploadError.value = (e as Error).message
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
uploading.value = false
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function removeAttachment(index: number) {
|
|
62
|
+
const attachment = props.modelValue[index]
|
|
63
|
+
if (!attachment) return
|
|
64
|
+
deletingIndex.value = index
|
|
65
|
+
try {
|
|
66
|
+
await deleteAttachment(attachment)
|
|
67
|
+
emit('update:modelValue', props.modelValue.filter((_, i) => i !== index))
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
deletingIndex.value = null
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function fileIcon(type: string): string {
|
|
75
|
+
if (type === 'application/pdf') return 'i-lucide-file-text'
|
|
76
|
+
if (type.includes('zip') || type.includes('archive') || type.includes('compressed')) return 'i-lucide-archive'
|
|
77
|
+
if (type.includes('spreadsheet') || type.includes('excel') || type.includes('csv')) return 'i-lucide-table'
|
|
78
|
+
if (type.includes('presentation') || type.includes('powerpoint')) return 'i-lucide-presentation'
|
|
79
|
+
if (isImage(type)) return 'i-lucide-image'
|
|
80
|
+
if (type.includes('video')) return 'i-lucide-video'
|
|
81
|
+
if (type.includes('audio')) return 'i-lucide-music'
|
|
82
|
+
return 'i-lucide-file'
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function formatSize(bytes: number): string {
|
|
86
|
+
if (bytes < 1024) return `${bytes} B`
|
|
87
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
88
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
|
89
|
+
}
|
|
90
|
+
</script>
|
|
91
|
+
|
|
92
|
+
<template>
|
|
93
|
+
<div class="space-y-2">
|
|
94
|
+
<div
|
|
95
|
+
v-for="(attachment, i) in modelValue"
|
|
96
|
+
:key="attachment.path"
|
|
97
|
+
class="flex items-center gap-3 px-3 py-2 rounded-lg border border-default"
|
|
98
|
+
>
|
|
99
|
+
<img
|
|
100
|
+
v-if="isImage(attachment.type)"
|
|
101
|
+
:src="attachment.url"
|
|
102
|
+
class="size-10 rounded object-cover shrink-0"
|
|
103
|
+
>
|
|
104
|
+
<UIcon
|
|
105
|
+
v-else
|
|
106
|
+
:name="fileIcon(attachment.type)"
|
|
107
|
+
class="size-5 shrink-0 text-muted"
|
|
108
|
+
/>
|
|
109
|
+
<div class="flex-1 min-w-0">
|
|
110
|
+
<p class="text-sm font-medium truncate">
|
|
111
|
+
{{ attachment.name }}
|
|
112
|
+
</p>
|
|
113
|
+
<p class="text-xs text-muted">
|
|
114
|
+
{{ formatSize(attachment.size) }}
|
|
115
|
+
</p>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<UButton
|
|
119
|
+
v-if="editable"
|
|
120
|
+
icon="i-lucide-x"
|
|
121
|
+
size="xs"
|
|
122
|
+
color="error"
|
|
123
|
+
variant="ghost"
|
|
124
|
+
:loading="deletingIndex === i"
|
|
125
|
+
@click="removeAttachment(i)"
|
|
126
|
+
/>
|
|
127
|
+
<UButton
|
|
128
|
+
v-else
|
|
129
|
+
icon="i-lucide-download"
|
|
130
|
+
size="xs"
|
|
131
|
+
color="neutral"
|
|
132
|
+
variant="ghost"
|
|
133
|
+
:to="attachment.url"
|
|
134
|
+
target="_blank"
|
|
135
|
+
/>
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
<template v-if="editable && modelValue.length < maxFiles">
|
|
139
|
+
<input
|
|
140
|
+
ref="fileInput"
|
|
141
|
+
type="file"
|
|
142
|
+
multiple
|
|
143
|
+
class="hidden"
|
|
144
|
+
@change="onFileInputChange"
|
|
145
|
+
>
|
|
146
|
+
<UButton
|
|
147
|
+
icon="i-lucide-paperclip"
|
|
148
|
+
:label="`파일 첨부 (${modelValue.length} / ${maxFiles})`"
|
|
149
|
+
variant="outline"
|
|
150
|
+
color="neutral"
|
|
151
|
+
size="sm"
|
|
152
|
+
:loading="uploading"
|
|
153
|
+
@click="fileInput?.click()"
|
|
154
|
+
/>
|
|
155
|
+
<UProgress
|
|
156
|
+
v-if="uploading"
|
|
157
|
+
:model-value="Math.round(uploadProgress * 100)"
|
|
158
|
+
/>
|
|
159
|
+
<p
|
|
160
|
+
v-if="uploadError"
|
|
161
|
+
class="text-xs text-error"
|
|
162
|
+
>
|
|
163
|
+
{{ uploadError }}
|
|
164
|
+
</p>
|
|
165
|
+
</template>
|
|
166
|
+
<p
|
|
167
|
+
v-else-if="editable"
|
|
168
|
+
class="text-xs text-muted"
|
|
169
|
+
>
|
|
170
|
+
최대 {{ maxFiles }}개까지 첨부할 수 있습니다.
|
|
171
|
+
</p>
|
|
172
|
+
</div>
|
|
173
|
+
</template>
|