memi-board 0.4.2 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,7 @@
1
1
  import { ModerationResult } from '../types';
2
2
  /**
3
- * 글쓰기 전 블로킹 검열. 로컬 비속어 필터를 먼저 돌리고, 통과하면 Firebase AI Logic(Gemini)으로 재검사한다.
4
- * 검열 API 장애 시 동작은 moderation.onError 옵션을 따른다 (기본 'allow' — 검열 장애로 정상 글쓰기가 막히지 않도록).
5
- *
6
- * 알려진 한계: 이 검열은 클라이언트에서만 실행되므로, Firestore에 직접 쓰기하면 우회할 수 있다.
7
- * (서버가 없는 아키텍처의 근본적 한계 — README의 Security Model 참고)
3
+ * 글쓰기 전 블로킹 검열. 로컬 비속어 필터 → Firebase AI Logic(Gemini).
4
+ * App Check limited-use 토큰 사용.
8
5
  */
9
6
  export declare function useMemiBoardModeration(): {
10
7
  checkText: (text: string) => Promise<ModerationResult>;
@@ -23,6 +23,8 @@ export declare function useMemiBoardPosts(): {
23
23
  getPosts: (opts?: {
24
24
  pageSize?: number;
25
25
  cursor?: QueryDocumentSnapshot<DocumentData>;
26
+ /** 지정 시 해당 카테고리 글만 (boardSettings 의 category id) */
27
+ category?: string;
26
28
  }) => Promise<{
27
29
  posts: PostModel[];
28
30
  cursor: QueryDocumentSnapshot<DocumentData> | undefined;
package/dist/config.d.ts CHANGED
@@ -7,6 +7,8 @@ export interface MemiBoardModerationOptions {
7
7
  localBlocklist?: string[];
8
8
  onError?: 'allow' | 'block';
9
9
  moderateImages?: boolean;
10
+ /** AI Logic App Check limited-use 토큰 (재생 보호). 기본 true */
11
+ useLimitedUseAppCheckTokens?: boolean;
10
12
  }
11
13
  export interface MemiBoardConfig {
12
14
  collectionPrefix: string;
package/dist/index.js CHANGED
@@ -1,36 +1,36 @@
1
1
  import { computed as e, reactive as t, ref as n, watch as r } from "vue";
2
2
  import { useCollection as i, useCurrentUser as a, useDocument as o, useFirebaseApp as s, useFirebaseAuth as c, useFirestore as l } from "vuefire";
3
- import { collection as u, deleteDoc as d, deleteField as f, doc as p, getDoc as m, getDocs as h, increment as g, limit as _, orderBy as v, query as y, serverTimestamp as b, setDoc as x, startAfter as S, updateDoc as C, writeBatch as w } from "firebase/firestore";
4
- import { GoogleAuthProvider as T, OAuthProvider as E, createUserWithEmailAndPassword as D, signInWithEmailAndPassword as O, signInWithPopup as k, signOut as A, updateProfile as j } from "firebase/auth";
5
- import { deleteObject as M, getDownloadURL as N, getStorage as P, listAll as F, ref as I, uploadBytesResumable as L } from "firebase/storage";
6
- import { GoogleAIBackend as R, Schema as z, getAI as B, getGenerativeModel as V } from "firebase/ai";
3
+ import { collection as u, deleteDoc as d, deleteField as f, doc as p, getDoc as m, getDocs as h, increment as g, limit as _, orderBy as v, query as y, serverTimestamp as b, setDoc as x, startAfter as S, updateDoc as C, where as w, writeBatch as T } from "firebase/firestore";
4
+ import { GoogleAuthProvider as E, OAuthProvider as D, createUserWithEmailAndPassword as O, signInWithEmailAndPassword as k, signInWithPopup as A, signOut as j, updateProfile as ee } from "firebase/auth";
5
+ import { deleteObject as M, getDownloadURL as N, getStorage as P, listAll as te, ref as F, uploadBytesResumable as ne } from "firebase/storage";
6
+ import { GoogleAIBackend as I, Schema as L, getAI as R, getGenerativeModel as re } from "firebase/ai";
7
7
  //#region src/config.ts
8
- var H = "__MEMI_BOARD_CONFIG__";
9
- function U() {
8
+ var z = "__MEMI_BOARD_CONFIG__";
9
+ function B() {
10
10
  let e = globalThis;
11
- return e[H] || (e[H] = t({
11
+ return e[z] || (e[z] = t({
12
12
  collectionPrefix: "memiBoard",
13
13
  auth: { providers: ["google", "apple"] },
14
14
  moderation: {
15
15
  enabled: !0,
16
- model: "gemini-2.5-flash",
16
+ model: "gemini-3.5-flash-lite",
17
17
  onError: "allow",
18
18
  moderateImages: !1,
19
19
  localBlocklist: []
20
20
  }
21
- })), e[H];
21
+ })), e[z];
22
22
  }
23
- function W(e) {
24
- let t = U();
23
+ function ie(e) {
24
+ let t = B();
25
25
  e.collectionPrefix && (t.collectionPrefix = e.collectionPrefix), e.auth && Object.assign(t.auth, e.auth), e.moderation && Object.assign(t.moderation, e.moderation);
26
26
  }
27
- function G() {
28
- return U();
27
+ function V() {
28
+ return B();
29
29
  }
30
30
  //#endregion
31
31
  //#region src/composables/useMemiBoardAuth.ts
32
- function K() {
33
- let t = G(), i = l(), o = c(), s = a(), u = n("user"), d = n(!0);
32
+ function H() {
33
+ let t = V(), i = l(), o = c(), s = a(), u = n("user"), d = n(!0);
34
34
  function f(e) {
35
35
  return p(i, `${t.collectionPrefix}Users`, e);
36
36
  }
@@ -64,26 +64,26 @@ function K() {
64
64
  return o;
65
65
  }
66
66
  async function y() {
67
- return k(v(), new T());
67
+ return A(v(), new E());
68
68
  }
69
69
  async function S() {
70
- let e = new E("apple.com");
71
- return e.addScope("email"), e.addScope("name"), k(v(), e);
70
+ let e = new D("apple.com");
71
+ return e.addScope("email"), e.addScope("name"), A(v(), e);
72
72
  }
73
73
  async function w(e, t) {
74
- return O(v(), e, t);
74
+ return k(v(), e, t);
75
75
  }
76
- async function M(e, t, n) {
77
- let r = await D(v(), e, t);
78
- return n && await j(r.user, { displayName: n }), r;
76
+ async function T(e, t, n) {
77
+ let r = await O(v(), e, t);
78
+ return n && await ee(r.user, { displayName: n }), r;
79
79
  }
80
- async function N() {
81
- await A(v());
80
+ async function M() {
81
+ await j(v());
82
82
  }
83
- function P(e) {
83
+ function N(e) {
84
84
  return g.value && (s.value?.uid === e.authorUid || _.value);
85
85
  }
86
- function F(e) {
86
+ function P(e) {
87
87
  return g.value && (s.value?.uid === e.authorUid || _.value);
88
88
  }
89
89
  return {
@@ -94,38 +94,36 @@ function K() {
94
94
  signInWithGoogle: y,
95
95
  signInWithApple: S,
96
96
  signInWithEmail: w,
97
- signUpWithEmail: M,
98
- signOut: N,
99
- canEdit: P,
100
- canDelete: P,
101
- canDeleteComment: F
97
+ signUpWithEmail: T,
98
+ signOut: M,
99
+ canEdit: N,
100
+ canDelete: N,
101
+ canDeleteComment: P
102
102
  };
103
103
  }
104
104
  //#endregion
105
105
  //#region src/utils/slugify.ts
106
- function q(e) {
106
+ function U(e) {
107
107
  return e.trim().replace(/\s+/g, "-").replace(/[^a-zA-Z0-9가-힣ㄱ-ㅎㅏ-ㅣ-]/g, "").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
108
108
  }
109
- function J(e, t = 60) {
109
+ function W(e, t = 60) {
110
110
  return e.replace(/[^a-zA-Z0-9가-힣ㄱ-ㅎ._-]/g, "-").slice(0, t);
111
111
  }
112
112
  //#endregion
113
113
  //#region src/composables/useMemiBoardPosts.ts
114
- async function Y(e) {
115
- let t = await F(e).catch(() => ({
114
+ async function G(e) {
115
+ let t = await te(e).catch(() => ({
116
116
  items: [],
117
117
  prefixes: []
118
118
  }));
119
- await Promise.all([...t.items.map((e) => M(e)), ...t.prefixes.map((e) => Y(e))]);
119
+ await Promise.all([...t.items.map((e) => M(e)), ...t.prefixes.map((e) => G(e))]);
120
120
  }
121
- function X() {
122
- let e = G(), t = l(), n = s(), r = () => e.collectionPrefix, i = () => u(t, `${r()}Posts`), a = (e) => p(t, `${r()}Posts`, e), o = (e) => p(t, `${r()}Posts`, e, "body", "main"), c = (e) => u(t, `${r()}Posts`, e, "comments");
121
+ function K() {
122
+ let e = V(), t = l(), n = s(), r = () => e.collectionPrefix, i = () => u(t, `${r()}Posts`), a = (e) => p(t, `${r()}Posts`, e), o = (e) => p(t, `${r()}Posts`, e, "body", "main"), c = (e) => u(t, `${r()}Posts`, e, "comments");
123
123
  async function g(e = {}) {
124
- let t = e.pageSize ?? 20, n = e.cursor ? [
125
- v("createdAt", "desc"),
126
- S(e.cursor),
127
- _(t + 1)
128
- ] : [v("createdAt", "desc"), _(t + 1)], r = await h(y(i(), ...n)), a = r.docs.slice(0, t);
124
+ let t = e.pageSize ?? 20, n = [];
125
+ e.category && n.push(w("category", "==", e.category)), n.push(v("createdAt", "desc")), e.cursor && n.push(S(e.cursor)), n.push(_(t + 1));
126
+ let r = await h(y(i(), ...n)), a = r.docs.slice(0, t);
129
127
  return {
130
128
  posts: a.map((e) => ({
131
129
  id: e.id,
@@ -135,7 +133,7 @@ function X() {
135
133
  hasMore: r.docs.length > t
136
134
  };
137
135
  }
138
- async function T(e) {
136
+ async function E(e) {
139
137
  let [t, n] = await Promise.all([m(a(e)), m(o(e))]);
140
138
  return t.exists() ? {
141
139
  id: t.id,
@@ -143,8 +141,8 @@ function X() {
143
141
  content: n.exists() ? n.data().content ?? "" : ""
144
142
  } : null;
145
143
  }
146
- async function E(e) {
147
- let t = q(e.title) || "post", n = t, r = 1;
144
+ async function D(e) {
145
+ let t = U(e.title) || "post", n = t, r = 1;
148
146
  for (; (await m(a(n))).exists();) r++, n = `${t}-${r}`;
149
147
  return await Promise.all([x(a(n), {
150
148
  title: e.title,
@@ -161,7 +159,7 @@ function X() {
161
159
  updatedAt: b()
162
160
  }), x(o(n), { content: e.content })]), n;
163
161
  }
164
- async function D(e, t) {
162
+ async function O(e, t) {
165
163
  await Promise.all([C(a(e), {
166
164
  title: t.title,
167
165
  tags: t.tags ?? [],
@@ -170,29 +168,29 @@ function X() {
170
168
  updatedAt: b()
171
169
  }), x(o(e), { content: t.content }, { merge: !0 })]);
172
170
  }
173
- async function O(e) {
171
+ async function k(e) {
174
172
  let i = await h(c(e));
175
173
  for (let e = 0; e < i.docs.length; e += 450) {
176
- let n = w(t);
174
+ let n = T(t);
177
175
  i.docs.slice(e, e + 450).forEach((e) => n.delete(e.ref)), await n.commit();
178
176
  }
179
177
  let s = P(n);
180
- await d(o(e)), await Y(I(s, `${r()}/posts/${e}`)), await d(a(e));
178
+ await d(o(e)), await G(F(s, `${r()}/posts/${e}`)), await d(a(e));
181
179
  }
182
180
  return {
183
181
  getPosts: g,
184
- getPost: T,
185
- createPost: E,
186
- updatePost: D,
187
- deletePost: O
182
+ getPost: E,
183
+ createPost: D,
184
+ updatePost: O,
185
+ deletePost: k
188
186
  };
189
187
  }
190
188
  //#endregion
191
189
  //#region src/composables/useMemiBoardComments.ts
192
- function ee(t) {
193
- let n = G(), r = l(), a = () => n.collectionPrefix, o = e(() => typeof t == "string" ? t : t.value), s = e(() => y(u(r, `${a()}Posts`, o.value, "comments"), v("createdAt", "asc"))), c = i(s);
190
+ function q(t) {
191
+ let n = V(), r = l(), a = () => n.collectionPrefix, o = e(() => typeof t == "string" ? t : t.value), s = e(() => y(u(r, `${a()}Posts`, o.value, "comments"), v("createdAt", "asc"))), c = i(s);
194
192
  async function d(e) {
195
- let t = w(r), n = a(), i = p(u(r, `${n}Posts`, o.value, "comments"));
193
+ let t = T(r), n = a(), i = p(u(r, `${n}Posts`, o.value, "comments"));
196
194
  t.set(i, {
197
195
  postId: o.value,
198
196
  body: e.body,
@@ -204,7 +202,7 @@ function ee(t) {
204
202
  }), t.update(p(r, `${n}Posts`, o.value), { commentCount: g(1) }), await t.commit();
205
203
  }
206
204
  async function f(e) {
207
- let t = w(r), n = a();
205
+ let t = T(r), n = a();
208
206
  t.delete(p(r, `${n}Posts`, o.value, "comments", e)), t.update(p(r, `${n}Posts`, o.value), { commentCount: g(-1) }), await t.commit();
209
207
  }
210
208
  return {
@@ -215,9 +213,10 @@ function ee(t) {
215
213
  }
216
214
  //#endregion
217
215
  //#region src/utils/moderation-prompt.ts
218
- var te = "당신은 게시판 콘텐츠를 심사하는 엄격한 모더레이터입니다.\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은 \"\"", ne = [
216
+ var J = "당신은 게시판 콘텐츠를 심사하는 엄격한 모더레이터입니다.\n아래 텍스트에 욕설·비속어·성희롱·혐오·위협·스팸·도배성 광고가 있으면 반드시 거부(flagged=true)합니다.\n\nflagged=true 예:\n- 욕설/비속어: 씨발, 시발, 병신, 지랄, 좆, 개새끼, 존나, fuck, shit, bitch 등\n- 성적 모욕, 혐오 발언, 협박\n- 게시판 주제와 무관한 도배성 광고/스팸\n\nflagged=false:\n- 게시판 주제에 맞는 정상적인 글/댓글 (강한 의견이나 정당한 비판은 허용)\n\nJSON만 출력:\n{\"flagged\":boolean,\"category\":\"none\"|\"abuse\"|\"spam\"|\"adult\"|\"violence\"|\"other\",\"reason\":string}\n- flagged=true면 category는 상황에 맞게 고르고, reason은 한국어 1문장 (예: \"욕설이 포함되어 있어 게시할 수 없습니다.\")\n- flagged=false면 category는 \"none\", reason은 \"\"", ae = [
219
217
  "씨발",
220
218
  "시발",
219
+ "시이발",
221
220
  "씨팔",
222
221
  "시팔",
223
222
  "병신",
@@ -227,6 +226,7 @@ var te = "당신은 게시판 콘텐츠를 심사하는 엄격한 모더레이
227
226
  "존나",
228
227
  "개새끼",
229
228
  "개새",
229
+ "새끼",
230
230
  "꺼져",
231
231
  "닥쳐",
232
232
  "씹",
@@ -239,10 +239,10 @@ var te = "당신은 게시판 콘텐츠를 심사하는 엄격한 모더레이
239
239
  "bitch",
240
240
  "asshole"
241
241
  ];
242
- function re(e) {
242
+ function oe(e) {
243
243
  return new RegExp(e.join("|"), "i");
244
244
  }
245
- var ie = /* @__PURE__ */ new Set([
245
+ var se = /* @__PURE__ */ new Set([
246
246
  "none",
247
247
  "abuse",
248
248
  "spam",
@@ -250,7 +250,7 @@ var ie = /* @__PURE__ */ new Set([
250
250
  "violence",
251
251
  "other"
252
252
  ]);
253
- function Z(e) {
253
+ function Y(e) {
254
254
  let t = e.trim();
255
255
  if (!t) return null;
256
256
  let n;
@@ -268,7 +268,7 @@ function Z(e) {
268
268
  if (typeof n != "object" || !n) return null;
269
269
  let r = n;
270
270
  if (typeof r.flagged != "boolean") return null;
271
- let i = typeof r.category == "string" && ie.has(r.category) ? r.category : r.flagged ? "other" : "none", a = typeof r.reason == "string" ? r.reason : "";
271
+ let i = typeof r.category == "string" && se.has(r.category) ? r.category : r.flagged ? "other" : "none", a = typeof r.reason == "string" ? r.reason : "";
272
272
  return {
273
273
  flagged: r.flagged,
274
274
  category: i,
@@ -277,85 +277,162 @@ function Z(e) {
277
277
  }
278
278
  //#endregion
279
279
  //#region src/composables/useMemiBoardModeration.ts
280
- var Q = {
280
+ var X = {
281
281
  flagged: !1,
282
282
  category: "none",
283
- reason: ""
283
+ reason: "",
284
+ via: "empty"
284
285
  };
285
- function ae() {
286
- let e = G().moderation ?? {}, t = s(), n = re([...ne, ...e.localBlocklist ?? []]);
286
+ function Z(e, t) {
287
+ console.info(`[memi-board:moderation] ${e}`, t);
288
+ }
289
+ function Q() {
290
+ let e = V().moderation ?? {}, t = s(), n = oe([...ae, ...e.localBlocklist ?? []]);
287
291
  function r(e) {
288
- return n.test(e) ? {
292
+ return e.match(n) ? {
289
293
  flagged: !0,
290
294
  category: "abuse",
291
- reason: "욕설·비속어가 포함되어 게시할 수 없습니다. 표현을 바꿔 주세요."
295
+ reason: "욕설·비속어가 포함되어 게시할 수 없습니다. 표현을 바꿔 주세요.",
296
+ via: "local"
292
297
  } : null;
293
298
  }
294
299
  function i() {
295
- let n = B(t, {
296
- backend: new R(),
297
- useLimitedUseAppCheckTokens: !0
298
- });
299
- return V(n, {
300
- model: e.model ?? "gemini-2.5-flash",
301
- systemInstruction: te,
302
- generationConfig: {
303
- temperature: 0,
304
- maxOutputTokens: 256,
305
- responseMimeType: "application/json",
306
- responseSchema: z.object({ properties: {
307
- flagged: z.boolean(),
308
- category: z.string(),
309
- reason: z.string()
310
- } })
311
- }
300
+ let n = e.model ?? "gemini-3.5-flash-lite", r = e.useLimitedUseAppCheckTokens !== !1, i = R(t, {
301
+ backend: new I(),
302
+ useLimitedUseAppCheckTokens: r
312
303
  });
304
+ return {
305
+ modelName: n,
306
+ useLimited: r,
307
+ model: re(i, {
308
+ model: n,
309
+ systemInstruction: J,
310
+ generationConfig: {
311
+ temperature: 0,
312
+ maxOutputTokens: 256,
313
+ responseMimeType: "application/json",
314
+ responseSchema: L.object({ properties: {
315
+ flagged: L.boolean(),
316
+ category: L.string(),
317
+ reason: L.string()
318
+ } })
319
+ }
320
+ })
321
+ };
313
322
  }
314
323
  function a() {
315
324
  return e.onError === "block" ? {
316
325
  flagged: !0,
317
326
  category: "other",
318
- reason: "검열 서비스에 일시적으로 연결할 수 없어 제출이 보류되었습니다. 잠시 후 다시 시도해 주세요.",
319
- error: !0
327
+ reason: "AI 검열에 실패해 글을 등록할 수 없습니다. (App Check/네트워크 확인) 잠시 후 다시 시도해 주세요.",
328
+ error: !0,
329
+ via: "ai-error-block"
320
330
  } : {
321
- ...Q,
322
- error: !0
331
+ flagged: !1,
332
+ category: "none",
333
+ reason: "",
334
+ error: !0,
335
+ via: "ai-error-allow"
323
336
  };
324
337
  }
325
338
  async function o(t) {
326
- let n = t.trim();
327
- if (!n) return Q;
328
- let o = r(n);
329
- if (o) return o;
330
- if (e.enabled === !1) return Q;
339
+ let o = t.trim();
340
+ if (Z("start", {
341
+ preview: o.slice(0, 80),
342
+ length: o.length,
343
+ enabled: e.enabled !== !1,
344
+ model: e.model ?? "gemini-3.5-flash-lite",
345
+ onError: e.onError ?? "allow"
346
+ }), !o) {
347
+ let e = {
348
+ ...X,
349
+ via: "empty"
350
+ };
351
+ return Z("result", e), e;
352
+ }
353
+ let s = r(o);
354
+ if (s) {
355
+ let e = o.match(n)?.[0];
356
+ return Z("local-block", {
357
+ hit: e,
358
+ ...s
359
+ }), Z("result", s), s;
360
+ }
361
+ if (Z("local-pass", { message: "로컬 금칙어 미매칭 → AI Logic 호출" }), e.enabled === !1) {
362
+ let e = {
363
+ ...X,
364
+ via: "disabled"
365
+ };
366
+ return Z("result", e), e;
367
+ }
331
368
  try {
332
- let e = Z((await i().generateContent(`심사할 텍스트:\n"""${n.slice(0, 4e3)}"""`)).response.text());
333
- return e ? {
334
- flagged: e.flagged,
335
- category: e.category,
336
- reason: e.reason
337
- } : (console.warn("[memi-board] 검열 응답 파싱 실패 → onError 정책 적용"), a());
369
+ let { model: e, modelName: t, useLimited: n } = i();
370
+ Z("ai-call", {
371
+ modelName: t,
372
+ useLimitedUseAppCheckTokens: n
373
+ });
374
+ let r = performance.now(), s = await e.generateContent(`심사할 텍스트:\n"""${o.slice(0, 4e3)}"""`), c = Math.round(performance.now() - r), l = s.response.text();
375
+ Z("ai-raw", {
376
+ ms: c,
377
+ raw: l.slice(0, 500)
378
+ });
379
+ let u = Y(l);
380
+ if (!u) {
381
+ Z("ai-parse-fail", { raw: l.slice(0, 300) });
382
+ let e = a();
383
+ return Z("result", e), e;
384
+ }
385
+ let d = {
386
+ flagged: u.flagged,
387
+ category: u.category,
388
+ reason: u.reason,
389
+ via: "ai"
390
+ };
391
+ return Z("result", {
392
+ ...d,
393
+ ms: c
394
+ }), d;
338
395
  } catch (e) {
339
- return console.warn("[memi-board] AI 검열 호출 실패 → onError 정책 적용", e), a();
396
+ let t = e;
397
+ Z("ai-error", {
398
+ message: e instanceof Error ? e.message : String(e),
399
+ name: e instanceof Error ? e.name : void 0,
400
+ code: t?.code,
401
+ cause: t?.cause instanceof Error ? t.cause.message : t?.cause
402
+ });
403
+ let n = a();
404
+ return Z("result", n), n;
340
405
  }
341
406
  }
342
407
  async function c(t) {
343
- if (e.enabled === !1 || !e.moderateImages) return Q;
408
+ if (e.enabled === !1 || !e.moderateImages) return {
409
+ ...X,
410
+ via: "disabled"
411
+ };
344
412
  try {
345
413
  let e = await new Promise((e, n) => {
346
414
  let r = new FileReader();
347
415
  r.onload = () => e(r.result.split(",")[1] ?? ""), r.onerror = n, r.readAsDataURL(t);
348
- }), n = Z((await i().generateContent([{ inlineData: {
416
+ }), { model: n, modelName: r } = i();
417
+ Z("ai-image-call", {
418
+ modelName: r,
419
+ type: t.type,
420
+ size: t.size
421
+ });
422
+ let o = (await n.generateContent([{ inlineData: {
349
423
  mimeType: t.type || "image/jpeg",
350
424
  data: e
351
- } }, "이 이미지를 심사해 주세요."])).response.text());
352
- return n ? {
353
- flagged: n.flagged,
354
- category: n.category,
355
- reason: n.reason
356
- } : a();
425
+ } }, "이 이미지를 심사해 주세요."])).response.text(), s = Y(o);
426
+ if (!s) return Z("ai-image-parse-fail", { raw: o.slice(0, 300) }), a();
427
+ let c = {
428
+ flagged: s.flagged,
429
+ category: s.category,
430
+ reason: s.reason,
431
+ via: "ai"
432
+ };
433
+ return Z("ai-image-result", c), c;
357
434
  } catch (e) {
358
- return console.warn("[memi-board] 이미지 검열 호출 실패 → onError 정책 적용", e), a();
435
+ return Z("ai-image-error", { message: e instanceof Error ? e.message : String(e) }), a();
359
436
  }
360
437
  }
361
438
  return {
@@ -365,10 +442,10 @@ function ae() {
365
442
  }
366
443
  //#endregion
367
444
  //#region src/composables/useMemiBoardStorage.ts
368
- function oe() {
369
- let e = G(), t = s(), n = () => e.collectionPrefix;
445
+ function ce() {
446
+ let e = V(), t = s(), n = () => e.collectionPrefix;
370
447
  function r(e, r, i) {
371
- let a = P(t), o = `${n()}/posts/${r}/attachments/${Date.now()}-${J(e.name)}`, s = I(a, o), c = L(s, e, {
448
+ let a = P(t), o = `${n()}/posts/${r}/attachments/${Date.now()}-${W(e.name)}`, s = F(a, o), c = ne(s, e, {
372
449
  contentType: e.type || "application/octet-stream",
373
450
  contentDisposition: `attachment; filename*=UTF-8''${encodeURIComponent(e.name)}`
374
451
  });
@@ -392,7 +469,7 @@ function oe() {
392
469
  }
393
470
  async function i(e) {
394
471
  let n = P(t);
395
- await M(I(n, e.path)).catch(() => {});
472
+ await M(F(n, e.path)).catch(() => {});
396
473
  }
397
474
  return {
398
475
  uploadAttachment: r,
@@ -415,8 +492,8 @@ var $ = [
415
492
  label: "질문"
416
493
  }
417
494
  ];
418
- function se() {
419
- let t = G(), n = l(), { isSignedIn: i, isAdmin: a } = K(), s = e(() => p(n, `${t.collectionPrefix}Settings`, "config")), { data: c, pending: u } = o(s), d = e(() => {
495
+ function le() {
496
+ let t = V(), n = l(), { isSignedIn: i, isAdmin: a } = H(), s = e(() => p(n, `${t.collectionPrefix}Settings`, "config")), { data: c, pending: u } = o(s), d = e(() => {
420
497
  let e = c.value?.categories;
421
498
  return e && e.length > 0 ? e : $;
422
499
  });
@@ -436,9 +513,9 @@ function se() {
436
513
  if (!t) throw Error("카테고리 이름을 입력해 주세요.");
437
514
  if (!i.value) throw Error("로그인이 필요합니다.");
438
515
  await h();
439
- let n = d.value.find((e) => e.label === t || e.id === q(t));
516
+ let n = d.value.find((e) => e.label === t || e.id === U(t));
440
517
  if (n) return n.id;
441
- let r = q(t) || `cat-${Date.now()}`, a = new Set(d.value.map((e) => e.id));
518
+ let r = U(t) || `cat-${Date.now()}`, a = new Set(d.value.map((e) => e.id));
442
519
  if (a.has(r)) {
443
520
  let e = 2;
444
521
  for (; a.has(`${r}-${e}`);) e++;
@@ -473,7 +550,7 @@ function se() {
473
550
  }
474
551
  //#endregion
475
552
  //#region src/data/versionHistory.ts
476
- var ce = [
553
+ var ue = [
477
554
  {
478
555
  version: "0.2.2",
479
556
  date: "2026-07-31",
@@ -500,10 +577,10 @@ var ce = [
500
577
  ];
501
578
  //#endregion
502
579
  //#region src/utils/formatDate.ts
503
- function le(e) {
580
+ function de(e) {
504
581
  return e?.toDate ? e.toDate().toLocaleDateString("ko-KR") : "";
505
582
  }
506
583
  //#endregion
507
- export { $ as DEFAULT_CATEGORIES, W as configureMemiBoard, le as formatDate, K as useMemiBoardAuth, ee as useMemiBoardComments, G as useMemiBoardConfig, ae as useMemiBoardModeration, X as useMemiBoardPosts, se as useMemiBoardSettings, oe as useMemiBoardStorage, ce as versionHistory };
584
+ export { $ as DEFAULT_CATEGORIES, ie as configureMemiBoard, de as formatDate, H as useMemiBoardAuth, q as useMemiBoardComments, V as useMemiBoardConfig, Q as useMemiBoardModeration, K as useMemiBoardPosts, le as useMemiBoardSettings, ce as useMemiBoardStorage, ue as versionHistory };
508
585
 
509
586
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../src/config.ts","../src/composables/useMemiBoardAuth.ts","../src/utils/slugify.ts","../src/composables/useMemiBoardPosts.ts","../src/composables/useMemiBoardComments.ts","../src/utils/moderation-prompt.ts","../src/composables/useMemiBoardModeration.ts","../src/composables/useMemiBoardStorage.ts","../src/composables/useMemiBoardSettings.ts","../src/data/versionHistory.ts","../src/utils/formatDate.ts"],"sourcesContent":["import { reactive } from 'vue'\n\nexport interface MemiBoardAuthOptions {\n providers?: Array<'google' | 'apple' | 'emailPassword'>\n}\n\nexport interface MemiBoardModerationOptions {\n enabled?: boolean\n model?: string\n localBlocklist?: string[]\n onError?: 'allow' | 'block'\n moderateImages?: boolean\n}\n\nexport interface MemiBoardConfig {\n collectionPrefix: string\n auth: MemiBoardAuthOptions\n moderation: MemiBoardModerationOptions\n}\n\nconst GLOBAL_KEY = '__MEMI_BOARD_CONFIG__' as const\n\n/**\n * link:/file: 모노레포에서 Vite 가 dist 와 src 를 서로 다른 모듈 id 로 묶으면\n * 모듈 스코프 singleton 이 둘로 갈라져 configure 가 안 먹는 문제가 난다.\n * globalThis 에 한 번만 올려 플러그인·컴포넌트가 항상 같은 config 를 보게 한다.\n */\nfunction getSharedConfig(): MemiBoardConfig {\n const g = globalThis as typeof globalThis & {\n [GLOBAL_KEY]?: MemiBoardConfig\n }\n if (!g[GLOBAL_KEY]) {\n g[GLOBAL_KEY] = reactive<MemiBoardConfig>({\n collectionPrefix: 'memiBoard',\n auth: { providers: ['google', 'apple'] },\n moderation: {\n enabled: true,\n model: 'gemini-2.5-flash',\n onError: 'allow',\n moderateImages: false,\n localBlocklist: [],\n },\n })\n }\n return g[GLOBAL_KEY]\n}\n\n/**\n * 앱 부팅 시(플러그인 등에서) 한 번 호출한다. 모든 요청/세션이 같은 값을 보므로\n * 서버 렌더링에서도 안전하다 — 사용자별/요청별로 달라지는 값이 아니다.\n */\nexport function configureMemiBoard(options: Partial<MemiBoardConfig>): void {\n const config = getSharedConfig()\n if (options.collectionPrefix) config.collectionPrefix = options.collectionPrefix\n if (options.auth) Object.assign(config.auth, options.auth)\n if (options.moderation) Object.assign(config.moderation, options.moderation)\n}\n\nexport function useMemiBoardConfig(): MemiBoardConfig {\n return getSharedConfig()\n}\n","import { ref, computed, watch } from 'vue'\nimport type { Ref, ComputedRef } from 'vue'\nimport { useFirestore, useFirebaseAuth, useCurrentUser } from 'vuefire'\nimport { doc, getDoc, setDoc, updateDoc, serverTimestamp } from 'firebase/firestore'\nimport {\n GoogleAuthProvider,\n OAuthProvider,\n signInWithPopup,\n signInWithEmailAndPassword,\n createUserWithEmailAndPassword,\n signOut as firebaseSignOut,\n updateProfile,\n} from 'firebase/auth'\nimport type { User } from 'firebase/auth'\nimport { useMemiBoardConfig } from '../config'\nimport type { PostModel, CommentModel } from '../types'\n\nexport interface UseMemiBoardAuthReturn {\n user: ReturnType<typeof useCurrentUser>\n isSignedIn: ComputedRef<boolean>\n isAdmin: ComputedRef<boolean>\n rolePending: Ref<boolean>\n signInWithGoogle: () => ReturnType<typeof signInWithPopup>\n signInWithApple: () => ReturnType<typeof signInWithPopup>\n signInWithEmail: (email: string, password: string) => ReturnType<typeof signInWithEmailAndPassword>\n signUpWithEmail: (email: string, password: string, displayName?: string) => ReturnType<typeof createUserWithEmailAndPassword>\n signOut: () => Promise<void>\n canEdit: (post: Pick<PostModel, 'authorUid'>) => boolean\n canDelete: (post: Pick<PostModel, 'authorUid'>) => boolean\n canDeleteComment: (comment: Pick<CommentModel, 'authorUid'>) => boolean\n}\n\n/**\n * 인증 상태 + 게시판 역할(role) 관리.\n * role은 {prefix}Users/{uid} 문서에서만 읽고, 모듈은 절대 'admin'을 쓰지 않는다\n * (관리자 승격은 호스트 프로젝트 소유자가 Firebase 콘솔에서 직접 설정).\n */\nexport function useMemiBoardAuth(): UseMemiBoardAuthReturn {\n const config = useMemiBoardConfig()\n const db = useFirestore()\n const auth = useFirebaseAuth()\n const user = useCurrentUser()\n\n const role = ref<'user' | 'admin'>('user')\n const rolePending = ref(true)\n\n function roleDocRef(uid: string) {\n return doc(db, `${config.collectionPrefix}Users`, uid)\n }\n\n /** 최초 로그인 시 역할 문서를 생성(role: 'user' 고정)하고, 이후에는 표시용 정보만 갱신한다. */\n async function ensureUserDoc(firebaseUser: User): Promise<'user' | 'admin'> {\n const ref = roleDocRef(firebaseUser.uid)\n const snap = await getDoc(ref)\n if (!snap.exists()) {\n await setDoc(ref, {\n role: 'user',\n displayName: firebaseUser.displayName,\n updatedAt: serverTimestamp(),\n })\n return 'user'\n }\n await updateDoc(ref, {\n displayName: firebaseUser.displayName,\n updatedAt: serverTimestamp(),\n })\n return (snap.data().role as 'user' | 'admin') ?? 'user'\n }\n\n watch(\n user,\n async (current) => {\n rolePending.value = true\n if (!current) {\n role.value = 'user'\n rolePending.value = false\n return\n }\n try {\n role.value = await ensureUserDoc(current)\n }\n catch {\n role.value = 'user'\n }\n finally {\n rolePending.value = false\n }\n },\n { immediate: true },\n )\n\n const isSignedIn = computed(() => !!user.value)\n const isAdmin = computed(() => role.value === 'admin')\n\n function requireAuth() {\n if (!auth) throw new Error('[memi-board] Firebase Auth가 초기화되지 않았습니다.')\n return auth\n }\n\n async function signInWithGoogle() {\n return signInWithPopup(requireAuth(), new GoogleAuthProvider())\n }\n\n async function signInWithApple() {\n const provider = new OAuthProvider('apple.com')\n provider.addScope('email')\n provider.addScope('name')\n return signInWithPopup(requireAuth(), provider)\n }\n\n async function signInWithEmail(email: string, password: string) {\n return signInWithEmailAndPassword(requireAuth(), email, password)\n }\n\n async function signUpWithEmail(email: string, password: string, displayName?: string) {\n const credential = await createUserWithEmailAndPassword(requireAuth(), email, password)\n if (displayName) await updateProfile(credential.user, { displayName })\n return credential\n }\n\n async function signOut() {\n await firebaseSignOut(requireAuth())\n }\n\n function canEdit(post: Pick<PostModel, 'authorUid'>) {\n return isSignedIn.value && (user.value?.uid === post.authorUid || isAdmin.value)\n }\n\n function canDeleteComment(comment: Pick<CommentModel, 'authorUid'>) {\n return isSignedIn.value && (user.value?.uid === comment.authorUid || isAdmin.value)\n }\n\n return {\n user,\n isSignedIn,\n isAdmin,\n rolePending,\n signInWithGoogle,\n signInWithApple,\n signInWithEmail,\n signUpWithEmail,\n signOut,\n canEdit,\n canDelete: canEdit,\n canDeleteComment,\n }\n}\n","/**\n * 제목 문자열을 URL-friendly slug로 변환\n * e.g. \"게시판 모듈 만들기!!\" → \"게시판-모듈-만들기\"\n */\nexport function slugify(text: string): string {\n return text\n .trim()\n .replace(/\\s+/g, '-') // 공백(연속 포함) → 하이픈\n .replace(/[^a-zA-Z0-9가-힣ㄱ-ㅎㅏ-ㅣ-]/g, '') // 허용 문자 외 제거\n .replace(/-+/g, '-') // 연속 하이픈 → 단일 하이픈\n .replace(/^-+|-+$/g, '') // 앞뒤 하이픈 제거\n}\n\n/** Storage 경로/파일명에 안전한 문자열로 변환 (한글은 유지) */\nexport function safeFileName(name: string, maxLength = 60): string {\n return name.replace(/[^a-zA-Z0-9가-힣ㄱ-ㅎ._-]/g, '-').slice(0, maxLength)\n}\n","import {\n collection,\n doc,\n getDoc,\n getDocs,\n setDoc,\n updateDoc,\n deleteDoc,\n query,\n orderBy,\n limit as fbLimit,\n startAfter,\n serverTimestamp,\n writeBatch,\n deleteField,\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 // prefix 는 setup 시점에 고정하지 않는다 (configure 이전 호출·이중 인스턴스 대비)\n const prefix = () => config.collectionPrefix\n\n const postsCol = () => collection(db, `${prefix()}Posts`)\n const postDoc = (id: string) => doc(db, `${prefix()}Posts`, id)\n const bodyDoc = (id: string) => doc(db, `${prefix()}Posts`, id, 'body', 'main')\n const commentsCol = (id: string) => collection(db, `${prefix()}Posts`, id, 'comments')\n\n async function getPosts(opts: { 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 // 메타·본문을 같이 갱신. category 미선택 시 필드 제거(부분 갱신으로 예전 값이 남는 것 방지).\n await Promise.all([\n updateDoc(postDoc(id), {\n title: input.title,\n tags: input.tags ?? [],\n category: input.category ? input.category : deleteField(),\n attachments: input.attachments ?? [],\n updatedAt: serverTimestamp(),\n }),\n setDoc(bodyDoc(id), { content: input.content }, { merge: true }),\n ])\n }\n\n /** 댓글 서브컬렉션 → 본문 → Storage 폴더 → 메타 순으로 삭제한다. */\n async function deletePost(id: string): Promise<void> {\n const commentsSnap = await getDocs(commentsCol(id))\n // Firestore write batch는 최대 500건. 여유를 두고 450건씩 나눠 삭제한다.\n for (let offset = 0; offset < commentsSnap.docs.length; offset += 450) {\n const batch = writeBatch(db)\n commentsSnap.docs.slice(offset, offset + 450).forEach(d => batch.delete(d.ref))\n await batch.commit()\n }\n\n const storage = getStorage(app)\n // Firestore/Storage 규칙이 부모 게시글의 소유권을 조회하므로 부모 문서는 마지막에 삭제한다.\n await deleteDoc(bodyDoc(id))\n await deleteStorageFolder(storageRef(storage, `${prefix()}/posts/${id}`))\n await deleteDoc(postDoc(id))\n }\n\n return { getPosts, getPost, createPost, updatePost, deletePost }\n}\n","import { computed } from 'vue'\nimport type { Ref } from 'vue'\nimport { useCollection, useFirestore } from 'vuefire'\nimport { collection, doc, orderBy, query, serverTimestamp, writeBatch, increment } from 'firebase/firestore'\nimport { useMemiBoardConfig } from '../config'\nimport type { CommentModel } from '../types'\n\nexport interface AddCommentInput {\n body: string\n authorUid: string\n authorName: string | null\n authorPhoto: string | null\n}\n\n/** postId 서브컬렉션 댓글을 실시간(onSnapshot)으로 구독하고, 작성/삭제 시 부모 글의 commentCount를 같은 batch로 증감한다. */\nexport function useMemiBoardComments(postId: string | Ref<string>) {\n const config = useMemiBoardConfig()\n const db = useFirestore()\n const prefix = () => config.collectionPrefix\n\n const id = computed(() => (typeof postId === 'string' ? postId : postId.value))\n\n const commentsQuery = computed(() =>\n query(collection(db, `${prefix()}Posts`, id.value, 'comments'), orderBy('createdAt', 'asc')),\n )\n\n const comments = useCollection<CommentModel>(commentsQuery)\n\n async function addComment(input: AddCommentInput): Promise<void> {\n const batch = writeBatch(db)\n const p = prefix()\n const commentRef = doc(collection(db, `${p}Posts`, id.value, 'comments'))\n batch.set(commentRef, {\n postId: id.value,\n body: input.body,\n authorUid: input.authorUid,\n authorName: input.authorName,\n authorPhoto: input.authorPhoto,\n moderationStatus: 'approved',\n createdAt: serverTimestamp(),\n })\n batch.update(doc(db, `${p}Posts`, id.value), { commentCount: increment(1) })\n await batch.commit()\n }\n\n async function deleteComment(commentId: string): Promise<void> {\n const batch = writeBatch(db)\n const p = prefix()\n batch.delete(doc(db, `${p}Posts`, id.value, 'comments', commentId))\n batch.update(doc(db, `${p}Posts`, id.value), { commentCount: increment(-1) })\n await batch.commit()\n }\n\n return { comments, addComment, deleteComment }\n}\n","export const MODERATION_SYSTEM = `당신은 게시판 콘텐츠를 심사하는 엄격한 모더레이터입니다.\n아래 텍스트에 욕설·비속어·성희롱·혐오·위협·스팸·도배성 광고가 있으면 반드시 거부(flagged=true)합니다.\n\nflagged=true 예:\n- 욕설/비속어: 씨발, 시발, 병신, 지랄, 좆, 개새끼, 존나, fuck, shit, bitch 등\n- 성적 모욕, 혐오 발언, 협박\n- 게시판 주제와 무관한 도배성 광고/스팸\n\nflagged=false:\n- 게시판 주제에 맞는 정상적인 글/댓글 (강한 의견이나 정당한 비판은 허용)\n\nJSON만 출력:\n{\"flagged\":boolean,\"category\":\"none\"|\"abuse\"|\"spam\"|\"adult\"|\"violence\"|\"other\",\"reason\":string}\n- flagged=true면 category는 상황에 맞게 고르고, reason은 한국어 1문장 (예: \"욕설이 포함되어 있어 게시할 수 없습니다.\")\n- flagged=false면 category는 \"none\", reason은 \"\"`\n\n/** API 없이도 1차로 걸러내는 한국어/영어 비속어 (부분 문자열 매칭) */\nexport const DEFAULT_LOCAL_BLOCKLIST = [\n '씨발', '시발', '씨팔', '시팔', '병신', '븅신', '지랄', '좆', '존나',\n '개새끼', '개새', '꺼져', '닥쳐', '씹', '니미', '느금', '애미', '애비',\n 'fuck', 'shit', 'bitch', 'asshole',\n]\n\nexport function buildLocalBlockRegex(patterns: string[]): RegExp {\n return new RegExp(patterns.join('|'), 'i')\n}\n\nconst CATEGORIES = new Set(['none', 'abuse', 'spam', 'adult', 'violence', 'other'])\n\nexport function parseModerationJson(raw: string): { flagged: boolean, category: string, reason: string } | null {\n const trimmed = raw.trim()\n if (!trimmed) return null\n\n let parsed: unknown\n try {\n parsed = JSON.parse(trimmed)\n }\n catch {\n const match = trimmed.match(/\\{[\\s\\S]*\\}/)\n if (!match) return null\n try {\n parsed = JSON.parse(match[0])\n }\n catch {\n return null\n }\n }\n\n if (typeof parsed !== 'object' || parsed === null) return null\n const obj = parsed as Record<string, unknown>\n if (typeof obj.flagged !== 'boolean') return null\n\n const category = typeof obj.category === 'string' && CATEGORIES.has(obj.category) ? obj.category : (obj.flagged ? 'other' : 'none')\n const reason = typeof obj.reason === 'string' ? obj.reason : ''\n return { flagged: obj.flagged, category, reason }\n}\n","import { getAI, getGenerativeModel, GoogleAIBackend, Schema } from 'firebase/ai'\nimport { useFirebaseApp } from 'vuefire'\nimport { buildLocalBlockRegex, DEFAULT_LOCAL_BLOCKLIST, MODERATION_SYSTEM, parseModerationJson } from '../utils/moderation-prompt'\nimport { useMemiBoardConfig } from '../config'\nimport type { ModerationResult } 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, updateDoc, serverTimestamp } from 'firebase/firestore'\nimport { useMemiBoardConfig } from '../config'\nimport { useMemiBoardAuth } from './useMemiBoardAuth'\nimport { slugify } from '../utils/slugify'\nimport type { BoardCategory, BoardSettingsModel } from '../types'\n\n/** 설정 문서가 없을 때 UI·시드에 쓰는 기본 카테고리. */\nexport const DEFAULT_CATEGORIES: BoardCategory[] = [\n { id: 'free', label: '자유' },\n { id: 'notice', label: '공지' },\n { id: 'question', label: '질문' },\n]\n\n/**\n * 게시판 설정({prefix}Settings/config) — 카테고리 옵션 목록.\n *\n * - 읽기: 전체 공개 (useDocument 실시간)\n * - 문서 없음: DEFAULT 로 보여 주고, 로그인 사용자가 처음 쓰거나 추가할 때 시드 생성\n * - 카테고리 추가: 로그인 사용자 (글쓰기 폼에서 즉시 추가)\n */\nexport function useMemiBoardSettings() {\n const config = useMemiBoardConfig()\n const db = useFirestore()\n const { isSignedIn, isAdmin } = useMemiBoardAuth()\n\n const settingsRef = computed(() => doc(db, `${config.collectionPrefix}Settings`, 'config'))\n const { data: settingsDoc, pending: settingsPending } = useDocument<BoardSettingsModel>(settingsRef)\n\n const categories = computed<BoardCategory[]>(() => {\n const list = settingsDoc.value?.categories\n return list && list.length > 0 ? list : DEFAULT_CATEGORIES\n })\n\n function categoryLabel(id: string | undefined): string | undefined {\n if (!id) return undefined\n return categories.value.find(c => c.id === id)?.label ?? id\n }\n\n /** 설정 문서가 없으면 기본 카테고리로 한 번 생성. 로그인 필요. */\n async function ensureSettings(): Promise<void> {\n if (!isSignedIn.value) return\n const ref = settingsRef.value\n const snap = await getDoc(ref).catch(() => null)\n if (!snap || snap.exists()) return\n await setDoc(ref, {\n categories: DEFAULT_CATEGORIES,\n updatedAt: serverTimestamp(),\n })\n }\n\n /**\n * 카테고리 추가. label 로 id(slug) 생성, 중복 id 면 접미사.\n * @returns 추가된(또는 기존 동일 label 의) category id\n */\n async function addCategory(label: string): Promise<string> {\n const trimmed = label.trim()\n if (!trimmed) throw new Error('카테고리 이름을 입력해 주세요.')\n if (!isSignedIn.value) throw new Error('로그인이 필요합니다.')\n\n await ensureSettings()\n\n const existing = categories.value.find(\n c => c.label === trimmed || c.id === slugify(trimmed),\n )\n if (existing) return existing.id\n\n let id = slugify(trimmed) || `cat-${Date.now()}`\n const used = new Set(categories.value.map(c => c.id))\n if (used.has(id)) {\n let n = 2\n while (used.has(`${id}-${n}`)) n++\n id = `${id}-${n}`\n }\n\n const next: BoardCategory[] = [...categories.value, { id, label: trimmed }]\n const ref = settingsRef.value\n const snap = await getDoc(ref)\n if (snap.exists()) {\n await updateDoc(ref, {\n categories: next,\n updatedAt: serverTimestamp(),\n })\n }\n else {\n await setDoc(ref, {\n categories: next,\n updatedAt: serverTimestamp(),\n })\n }\n return id\n }\n\n // 관리자 방문 시 문서 없으면 시드 (기존 동작 유지 + 로그인 일반 사용자 ensure 와 병행)\n watch(\n [isAdmin, isSignedIn, settingsPending],\n async ([admin, signedIn, pending]) => {\n if (pending || settingsDoc.value) return\n if (!admin && !signedIn) return\n await ensureSettings().catch(() => {})\n },\n { immediate: true },\n )\n\n return {\n categories,\n categoryLabel,\n settingsPending,\n ensureSettings,\n addCategory,\n DEFAULT_CATEGORIES,\n }\n}\n","export interface VersionHistoryEntry {\n version: string\n date: string\n highlights: string[]\n note?: string\n}\n\n/**\n * MemiBoardVersionHistory 컴포넌트가 그대로 보여주는 데이터.\n * 코드 수준의 자세한 변경 내역은 이 레포의 CHANGELOG.md를 참고 — 새 버전을 릴리스할 때 이 배열도 함께 갱신한다.\n */\nexport const versionHistory: VersionHistoryEntry[] = [\n {\n version: '0.2.2',\n date: '2026-07-31',\n highlights: [\n '카테고리 — 글을 주제별로 분류해서 올릴 수 있어요',\n ],\n },\n {\n version: '0.2.0',\n date: '2026-07-31',\n highlights: [\n '글쓰기 / 수정 / 삭제 — 제목, 본문, 태그를 자유롭게 작성',\n '파일 첨부 — 이미지·문서를 게시글에 첨부, 이미지는 미리보기 제공',\n '댓글 — 실시간으로 댓글을 달고 지울 수 있음',\n '로그인 / 권한 — 로그인해야 글을 쓸 수 있고, 본인 글만 수정·삭제 가능(관리자는 전체 관리)',\n 'AI 자동 검열 — 욕설·부적절한 내용이 담긴 글/댓글은 AI가 자동으로 걸러 게시를 막음',\n '한 줄 설정으로 바로 시작 — pages: true 옵션 하나로 게시판 화면이 바로 만들어짐',\n ],\n note: '서버 없이 동작하는 모듈이라, 실제 보안은 Firebase 프로젝트에 규칙 파일을 등록해야 완성됩니다. README의 Security Model 안내를 꼭 따라주세요.',\n },\n {\n version: '0.1.0',\n date: '2026-07-27',\n highlights: [\n '프로젝트 최초 생성 (아직 게시판 기능은 없는 빈 Nuxt 4 프로젝트)',\n ],\n },\n]\n","import type { Timestamp } from 'firebase/firestore'\n\nexport function formatDate(ts: Timestamp | undefined): string {\n return ts?.toDate ? ts.toDate().toLocaleDateString('ko-KR') : ''\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../src/config.ts","../src/composables/useMemiBoardAuth.ts","../src/utils/slugify.ts","../src/composables/useMemiBoardPosts.ts","../src/composables/useMemiBoardComments.ts","../src/utils/moderation-prompt.ts","../src/composables/useMemiBoardModeration.ts","../src/composables/useMemiBoardStorage.ts","../src/composables/useMemiBoardSettings.ts","../src/data/versionHistory.ts","../src/utils/formatDate.ts"],"sourcesContent":["import { reactive } from 'vue'\n\nexport interface MemiBoardAuthOptions {\n providers?: Array<'google' | 'apple' | 'emailPassword'>\n}\n\nexport interface MemiBoardModerationOptions {\n enabled?: boolean\n model?: string\n localBlocklist?: string[]\n onError?: 'allow' | 'block'\n moderateImages?: boolean\n /** AI Logic App Check limited-use 토큰 (재생 보호). 기본 true */\n useLimitedUseAppCheckTokens?: boolean\n}\n\nexport interface MemiBoardConfig {\n collectionPrefix: string\n auth: MemiBoardAuthOptions\n moderation: MemiBoardModerationOptions\n}\n\nconst GLOBAL_KEY = '__MEMI_BOARD_CONFIG__' as const\n\n/**\n * link:/file: 모노레포에서 Vite 가 dist 와 src 를 서로 다른 모듈 id 로 묶으면\n * 모듈 스코프 singleton 이 둘로 갈라져 configure 가 안 먹는 문제가 난다.\n * globalThis 에 한 번만 올려 플러그인·컴포넌트가 항상 같은 config 를 보게 한다.\n */\nfunction getSharedConfig(): MemiBoardConfig {\n const g = globalThis as typeof globalThis & {\n [GLOBAL_KEY]?: MemiBoardConfig\n }\n if (!g[GLOBAL_KEY]) {\n g[GLOBAL_KEY] = reactive<MemiBoardConfig>({\n collectionPrefix: 'memiBoard',\n auth: { providers: ['google', 'apple'] },\n moderation: {\n enabled: true,\n model: 'gemini-3.5-flash-lite',\n onError: 'allow',\n moderateImages: false,\n localBlocklist: [],\n },\n })\n }\n return g[GLOBAL_KEY]\n}\n\n/**\n * 앱 부팅 시(플러그인 등에서) 한 번 호출한다. 모든 요청/세션이 같은 값을 보므로\n * 서버 렌더링에서도 안전하다 — 사용자별/요청별로 달라지는 값이 아니다.\n */\nexport function configureMemiBoard(options: Partial<MemiBoardConfig>): void {\n const config = getSharedConfig()\n if (options.collectionPrefix) config.collectionPrefix = options.collectionPrefix\n if (options.auth) Object.assign(config.auth, options.auth)\n if (options.moderation) Object.assign(config.moderation, options.moderation)\n}\n\nexport function useMemiBoardConfig(): MemiBoardConfig {\n return getSharedConfig()\n}\n","import { ref, computed, watch } from 'vue'\nimport type { Ref, ComputedRef } from 'vue'\nimport { useFirestore, useFirebaseAuth, useCurrentUser } from 'vuefire'\nimport { doc, getDoc, setDoc, updateDoc, serverTimestamp } from 'firebase/firestore'\nimport {\n GoogleAuthProvider,\n OAuthProvider,\n signInWithPopup,\n signInWithEmailAndPassword,\n createUserWithEmailAndPassword,\n signOut as firebaseSignOut,\n updateProfile,\n} from 'firebase/auth'\nimport type { User } from 'firebase/auth'\nimport { useMemiBoardConfig } from '../config'\nimport type { PostModel, CommentModel } from '../types'\n\nexport interface UseMemiBoardAuthReturn {\n user: ReturnType<typeof useCurrentUser>\n isSignedIn: ComputedRef<boolean>\n isAdmin: ComputedRef<boolean>\n rolePending: Ref<boolean>\n signInWithGoogle: () => ReturnType<typeof signInWithPopup>\n signInWithApple: () => ReturnType<typeof signInWithPopup>\n signInWithEmail: (email: string, password: string) => ReturnType<typeof signInWithEmailAndPassword>\n signUpWithEmail: (email: string, password: string, displayName?: string) => ReturnType<typeof createUserWithEmailAndPassword>\n signOut: () => Promise<void>\n canEdit: (post: Pick<PostModel, 'authorUid'>) => boolean\n canDelete: (post: Pick<PostModel, 'authorUid'>) => boolean\n canDeleteComment: (comment: Pick<CommentModel, 'authorUid'>) => boolean\n}\n\n/**\n * 인증 상태 + 게시판 역할(role) 관리.\n * role은 {prefix}Users/{uid} 문서에서만 읽고, 모듈은 절대 'admin'을 쓰지 않는다\n * (관리자 승격은 호스트 프로젝트 소유자가 Firebase 콘솔에서 직접 설정).\n */\nexport function useMemiBoardAuth(): UseMemiBoardAuthReturn {\n const config = useMemiBoardConfig()\n const db = useFirestore()\n const auth = useFirebaseAuth()\n const user = useCurrentUser()\n\n const role = ref<'user' | 'admin'>('user')\n const rolePending = ref(true)\n\n function roleDocRef(uid: string) {\n return doc(db, `${config.collectionPrefix}Users`, uid)\n }\n\n /** 최초 로그인 시 역할 문서를 생성(role: 'user' 고정)하고, 이후에는 표시용 정보만 갱신한다. */\n async function ensureUserDoc(firebaseUser: User): Promise<'user' | 'admin'> {\n const ref = roleDocRef(firebaseUser.uid)\n const snap = await getDoc(ref)\n if (!snap.exists()) {\n await setDoc(ref, {\n role: 'user',\n displayName: firebaseUser.displayName,\n updatedAt: serverTimestamp(),\n })\n return 'user'\n }\n await updateDoc(ref, {\n displayName: firebaseUser.displayName,\n updatedAt: serverTimestamp(),\n })\n return (snap.data().role as 'user' | 'admin') ?? 'user'\n }\n\n watch(\n user,\n async (current) => {\n rolePending.value = true\n if (!current) {\n role.value = 'user'\n rolePending.value = false\n return\n }\n try {\n role.value = await ensureUserDoc(current)\n }\n catch {\n role.value = 'user'\n }\n finally {\n rolePending.value = false\n }\n },\n { immediate: true },\n )\n\n const isSignedIn = computed(() => !!user.value)\n const isAdmin = computed(() => role.value === 'admin')\n\n function requireAuth() {\n if (!auth) throw new Error('[memi-board] Firebase Auth가 초기화되지 않았습니다.')\n return auth\n }\n\n async function signInWithGoogle() {\n return signInWithPopup(requireAuth(), new GoogleAuthProvider())\n }\n\n async function signInWithApple() {\n const provider = new OAuthProvider('apple.com')\n provider.addScope('email')\n provider.addScope('name')\n return signInWithPopup(requireAuth(), provider)\n }\n\n async function signInWithEmail(email: string, password: string) {\n return signInWithEmailAndPassword(requireAuth(), email, password)\n }\n\n async function signUpWithEmail(email: string, password: string, displayName?: string) {\n const credential = await createUserWithEmailAndPassword(requireAuth(), email, password)\n if (displayName) await updateProfile(credential.user, { displayName })\n return credential\n }\n\n async function signOut() {\n await firebaseSignOut(requireAuth())\n }\n\n function canEdit(post: Pick<PostModel, 'authorUid'>) {\n return isSignedIn.value && (user.value?.uid === post.authorUid || isAdmin.value)\n }\n\n function canDeleteComment(comment: Pick<CommentModel, 'authorUid'>) {\n return isSignedIn.value && (user.value?.uid === comment.authorUid || isAdmin.value)\n }\n\n return {\n user,\n isSignedIn,\n isAdmin,\n rolePending,\n signInWithGoogle,\n signInWithApple,\n signInWithEmail,\n signUpWithEmail,\n signOut,\n canEdit,\n canDelete: canEdit,\n canDeleteComment,\n }\n}\n","/**\n * 제목 문자열을 URL-friendly slug로 변환\n * e.g. \"게시판 모듈 만들기!!\" → \"게시판-모듈-만들기\"\n */\nexport function slugify(text: string): string {\n return text\n .trim()\n .replace(/\\s+/g, '-') // 공백(연속 포함) → 하이픈\n .replace(/[^a-zA-Z0-9가-힣ㄱ-ㅎㅏ-ㅣ-]/g, '') // 허용 문자 외 제거\n .replace(/-+/g, '-') // 연속 하이픈 → 단일 하이픈\n .replace(/^-+|-+$/g, '') // 앞뒤 하이픈 제거\n}\n\n/** Storage 경로/파일명에 안전한 문자열로 변환 (한글은 유지) */\nexport function safeFileName(name: string, maxLength = 60): string {\n return name.replace(/[^a-zA-Z0-9가-힣ㄱ-ㅎ._-]/g, '-').slice(0, maxLength)\n}\n","import {\n collection,\n doc,\n getDoc,\n getDocs,\n setDoc,\n updateDoc,\n deleteDoc,\n query,\n where,\n orderBy,\n limit as fbLimit,\n startAfter,\n serverTimestamp,\n writeBatch,\n deleteField,\n} from 'firebase/firestore'\nimport type { QueryConstraint } from 'firebase/firestore'\nimport type { DocumentData, QueryDocumentSnapshot } from 'firebase/firestore'\nimport { getStorage, ref as storageRef, listAll, deleteObject } from 'firebase/storage'\nimport type { StorageReference } from 'firebase/storage'\nimport { useFirebaseApp, useFirestore } from 'vuefire'\nimport { slugify } from '../utils/slugify'\nimport { useMemiBoardConfig } from '../config'\nimport type { Attachment, PostDetail, PostModel } from '../types'\n\nasync function deleteStorageFolder(folderRef: StorageReference): Promise<void> {\n const list = await listAll(folderRef).catch(() => ({ items: [], prefixes: [] }))\n await Promise.all([\n ...list.items.map(item => deleteObject(item)),\n ...list.prefixes.map(prefix => deleteStorageFolder(prefix)),\n ])\n}\n\nexport interface CreatePostInput {\n title: string\n content: string\n tags?: string[]\n category?: string\n attachments?: Attachment[]\n authorUid: string\n authorName: string | null\n authorPhoto: string | null\n moderationModel?: string\n}\n\nexport interface UpdatePostInput {\n title: string\n content: string\n tags?: string[]\n category?: string\n attachments?: Attachment[]\n}\n\n/** 목록 조회는 posts/{id} 메타만 읽고, 본문(body/main)은 상세 조회 시에만 읽는다. */\nexport function useMemiBoardPosts() {\n const config = useMemiBoardConfig()\n const db = useFirestore()\n const app = useFirebaseApp()\n // prefix 는 setup 시점에 고정하지 않는다 (configure 이전 호출·이중 인스턴스 대비)\n const prefix = () => config.collectionPrefix\n\n const postsCol = () => collection(db, `${prefix()}Posts`)\n const postDoc = (id: string) => doc(db, `${prefix()}Posts`, id)\n const bodyDoc = (id: string) => doc(db, `${prefix()}Posts`, id, 'body', 'main')\n const commentsCol = (id: string) => collection(db, `${prefix()}Posts`, id, 'comments')\n\n async function getPosts(opts: {\n pageSize?: number\n cursor?: QueryDocumentSnapshot<DocumentData>\n /** 지정 시 해당 카테고리 글만 (boardSettings 의 category id) */\n category?: string\n } = {}) {\n const pageSize = opts.pageSize ?? 20\n const constraints: QueryConstraint[] = []\n if (opts.category) {\n // 복합 인덱스: category ASC + createdAt DESC (호스트 firestore.indexes.json)\n constraints.push(where('category', '==', opts.category))\n }\n constraints.push(orderBy('createdAt', 'desc'))\n if (opts.cursor) constraints.push(startAfter(opts.cursor))\n constraints.push(fbLimit(pageSize + 1))\n\n const snapshot = await getDocs(query(postsCol(), ...constraints))\n const docs = snapshot.docs.slice(0, pageSize)\n return {\n posts: docs.map(d => ({ id: d.id, ...d.data() }) as PostModel),\n cursor: docs[docs.length - 1] as QueryDocumentSnapshot<DocumentData> | undefined,\n hasMore: snapshot.docs.length > pageSize,\n }\n }\n\n async function getPost(id: string): Promise<PostDetail | null> {\n const [metaSnap, bodySnap] = await Promise.all([getDoc(postDoc(id)), getDoc(bodyDoc(id))])\n if (!metaSnap.exists()) return null\n return {\n id: metaSnap.id,\n ...metaSnap.data(),\n content: bodySnap.exists() ? ((bodySnap.data() as { content: string }).content ?? '') : '',\n } as PostDetail\n }\n\n async function createPost(input: CreatePostInput): Promise<string> {\n const baseSlug = slugify(input.title) || 'post'\n let slug = baseSlug\n let counter = 1\n while ((await getDoc(postDoc(slug))).exists()) {\n counter++\n slug = `${baseSlug}-${counter}`\n }\n\n await Promise.all([\n setDoc(postDoc(slug), {\n title: input.title,\n tags: input.tags ?? [],\n ...(input.category ? { category: input.category } : {}),\n attachments: input.attachments ?? [],\n commentCount: 0,\n authorUid: input.authorUid,\n authorName: input.authorName,\n authorPhoto: input.authorPhoto,\n moderationStatus: 'approved',\n moderationModel: input.moderationModel ?? null,\n createdAt: serverTimestamp(),\n updatedAt: serverTimestamp(),\n }),\n setDoc(bodyDoc(slug), { content: input.content }),\n ])\n\n return slug\n }\n\n async function updatePost(id: string, input: UpdatePostInput): Promise<void> {\n // 메타·본문을 같이 갱신. category 미선택 시 필드 제거(부분 갱신으로 예전 값이 남는 것 방지).\n await Promise.all([\n updateDoc(postDoc(id), {\n title: input.title,\n tags: input.tags ?? [],\n category: input.category ? input.category : deleteField(),\n attachments: input.attachments ?? [],\n updatedAt: serverTimestamp(),\n }),\n setDoc(bodyDoc(id), { content: input.content }, { merge: true }),\n ])\n }\n\n /** 댓글 서브컬렉션 → 본문 → Storage 폴더 → 메타 순으로 삭제한다. */\n async function deletePost(id: string): Promise<void> {\n const commentsSnap = await getDocs(commentsCol(id))\n // Firestore write batch는 최대 500건. 여유를 두고 450건씩 나눠 삭제한다.\n for (let offset = 0; offset < commentsSnap.docs.length; offset += 450) {\n const batch = writeBatch(db)\n commentsSnap.docs.slice(offset, offset + 450).forEach(d => batch.delete(d.ref))\n await batch.commit()\n }\n\n const storage = getStorage(app)\n // Firestore/Storage 규칙이 부모 게시글의 소유권을 조회하므로 부모 문서는 마지막에 삭제한다.\n await deleteDoc(bodyDoc(id))\n await deleteStorageFolder(storageRef(storage, `${prefix()}/posts/${id}`))\n await deleteDoc(postDoc(id))\n }\n\n return { getPosts, getPost, createPost, updatePost, deletePost }\n}\n","import { computed } from 'vue'\nimport type { Ref } from 'vue'\nimport { useCollection, useFirestore } from 'vuefire'\nimport { collection, doc, orderBy, query, serverTimestamp, writeBatch, increment } from 'firebase/firestore'\nimport { useMemiBoardConfig } from '../config'\nimport type { CommentModel } from '../types'\n\nexport interface AddCommentInput {\n body: string\n authorUid: string\n authorName: string | null\n authorPhoto: string | null\n}\n\n/** postId 서브컬렉션 댓글을 실시간(onSnapshot)으로 구독하고, 작성/삭제 시 부모 글의 commentCount를 같은 batch로 증감한다. */\nexport function useMemiBoardComments(postId: string | Ref<string>) {\n const config = useMemiBoardConfig()\n const db = useFirestore()\n const prefix = () => config.collectionPrefix\n\n const id = computed(() => (typeof postId === 'string' ? postId : postId.value))\n\n const commentsQuery = computed(() =>\n query(collection(db, `${prefix()}Posts`, id.value, 'comments'), orderBy('createdAt', 'asc')),\n )\n\n const comments = useCollection<CommentModel>(commentsQuery)\n\n async function addComment(input: AddCommentInput): Promise<void> {\n const batch = writeBatch(db)\n const p = prefix()\n const commentRef = doc(collection(db, `${p}Posts`, id.value, 'comments'))\n batch.set(commentRef, {\n postId: id.value,\n body: input.body,\n authorUid: input.authorUid,\n authorName: input.authorName,\n authorPhoto: input.authorPhoto,\n moderationStatus: 'approved',\n createdAt: serverTimestamp(),\n })\n batch.update(doc(db, `${p}Posts`, id.value), { commentCount: increment(1) })\n await batch.commit()\n }\n\n async function deleteComment(commentId: string): Promise<void> {\n const batch = writeBatch(db)\n const p = prefix()\n batch.delete(doc(db, `${p}Posts`, id.value, 'comments', commentId))\n batch.update(doc(db, `${p}Posts`, id.value), { commentCount: increment(-1) })\n await batch.commit()\n }\n\n return { comments, addComment, deleteComment }\n}\n","export const MODERATION_SYSTEM = `당신은 게시판 콘텐츠를 심사하는 엄격한 모더레이터입니다.\n아래 텍스트에 욕설·비속어·성희롱·혐오·위협·스팸·도배성 광고가 있으면 반드시 거부(flagged=true)합니다.\n\nflagged=true 예:\n- 욕설/비속어: 씨발, 시발, 병신, 지랄, 좆, 개새끼, 존나, fuck, shit, bitch 등\n- 성적 모욕, 혐오 발언, 협박\n- 게시판 주제와 무관한 도배성 광고/스팸\n\nflagged=false:\n- 게시판 주제에 맞는 정상적인 글/댓글 (강한 의견이나 정당한 비판은 허용)\n\nJSON만 출력:\n{\"flagged\":boolean,\"category\":\"none\"|\"abuse\"|\"spam\"|\"adult\"|\"violence\"|\"other\",\"reason\":string}\n- flagged=true면 category는 상황에 맞게 고르고, reason은 한국어 1문장 (예: \"욕설이 포함되어 있어 게시할 수 없습니다.\")\n- flagged=false면 category는 \"none\", reason은 \"\"`\n\n/** API 없이도 1차로 걸러내는 한국어/영어 비속어 (부분 문자열 매칭) */\nexport const DEFAULT_LOCAL_BLOCKLIST = [\n '씨발', '시발', '시이발', '씨팔', '시팔', '병신', '븅신', '지랄', '좆', '존나',\n '개새끼', '개새', '새끼', '꺼져', '닥쳐', '씹', '니미', '느금', '애미', '애비',\n 'fuck', 'shit', 'bitch', 'asshole',\n]\n\nexport function buildLocalBlockRegex(patterns: string[]): RegExp {\n return new RegExp(patterns.join('|'), 'i')\n}\n\nconst CATEGORIES = new Set(['none', 'abuse', 'spam', 'adult', 'violence', 'other'])\n\nexport function parseModerationJson(raw: string): { flagged: boolean, category: string, reason: string } | null {\n const trimmed = raw.trim()\n if (!trimmed) return null\n\n let parsed: unknown\n try {\n parsed = JSON.parse(trimmed)\n }\n catch {\n const match = trimmed.match(/\\{[\\s\\S]*\\}/)\n if (!match) return null\n try {\n parsed = JSON.parse(match[0])\n }\n catch {\n return null\n }\n }\n\n if (typeof parsed !== 'object' || parsed === null) return null\n const obj = parsed as Record<string, unknown>\n if (typeof obj.flagged !== 'boolean') return null\n\n const category = typeof obj.category === 'string' && CATEGORIES.has(obj.category) ? obj.category : (obj.flagged ? 'other' : 'none')\n const reason = typeof obj.reason === 'string' ? obj.reason : ''\n return { flagged: obj.flagged, category, reason }\n}\n","import { getAI, getGenerativeModel, GoogleAIBackend, Schema } from 'firebase/ai'\nimport { useFirebaseApp } from 'vuefire'\nimport { buildLocalBlockRegex, DEFAULT_LOCAL_BLOCKLIST, MODERATION_SYSTEM, parseModerationJson } from '../utils/moderation-prompt'\nimport { useMemiBoardConfig } from '../config'\nimport type { ModerationResult, ModerationVia } from '../types'\n\nconst APPROVED: ModerationResult = { flagged: false, category: 'none', reason: '', via: 'empty' }\n\nfunction logModeration(\n phase: string,\n detail: Record<string, unknown> | object,\n) {\n // 개발·테스트에서 항상 보이게 (프로덕션도 한 줄 info — 필요 시 나중에 dev only)\n console.info(`[memi-board:moderation] ${phase}`, detail)\n}\n\n/**\n * 글쓰기 전 블로킹 검열. 로컬 비속어 필터 → Firebase AI Logic(Gemini).\n * App Check limited-use 토큰 사용.\n */\nexport function useMemiBoardModeration() {\n const config = useMemiBoardConfig()\n const moderation = config.moderation ?? {}\n const app = useFirebaseApp()\n\n const localBlockRe = buildLocalBlockRegex([...DEFAULT_LOCAL_BLOCKLIST, ...(moderation.localBlocklist ?? [])])\n\n function localCheck(text: string): ModerationResult | null {\n const match = text.match(localBlockRe)\n if (match) {\n return {\n flagged: true,\n category: 'abuse',\n reason: '욕설·비속어가 포함되어 게시할 수 없습니다. 표현을 바꿔 주세요.',\n via: 'local',\n }\n }\n return null\n }\n\n function getModerationModel() {\n const modelName = moderation.model ?? 'gemini-3.5-flash-lite'\n // limited-use 는 재생 보호용. 기본 App Check 세션 토큰으로도 AI Logic 호출 가능.\n // (App Check 미통과 시 limited-use 도 동일하게 실패 — 디버그 토큰/reCAPTCHA 가 우선)\n const useLimited = moderation.useLimitedUseAppCheckTokens !== false\n const ai = getAI(app, {\n backend: new GoogleAIBackend(),\n useLimitedUseAppCheckTokens: useLimited,\n })\n return {\n modelName,\n useLimited,\n model: getGenerativeModel(ai, {\n model: modelName,\n systemInstruction: MODERATION_SYSTEM,\n generationConfig: {\n temperature: 0,\n maxOutputTokens: 256,\n responseMimeType: 'application/json',\n responseSchema: Schema.object({\n properties: {\n flagged: Schema.boolean(),\n category: Schema.string(),\n reason: Schema.string(),\n },\n }),\n },\n }),\n }\n }\n\n function errorResult(): ModerationResult {\n if (moderation.onError === 'block') {\n return {\n flagged: true,\n category: 'other',\n reason:\n 'AI 검열에 실패해 글을 등록할 수 없습니다. (App Check/네트워크 확인) 잠시 후 다시 시도해 주세요.',\n error: true,\n via: 'ai-error-block',\n }\n }\n return {\n flagged: false,\n category: 'none',\n reason: '',\n error: true,\n via: 'ai-error-allow',\n }\n }\n\n async function checkText(text: string): Promise<ModerationResult> {\n const trimmed = text.trim()\n const preview = trimmed.slice(0, 80)\n\n logModeration('start', {\n preview,\n length: trimmed.length,\n enabled: moderation.enabled !== false,\n model: moderation.model ?? 'gemini-3.5-flash-lite',\n onError: moderation.onError ?? 'allow',\n })\n\n if (!trimmed) {\n const r = { ...APPROVED, via: 'empty' as ModerationVia }\n logModeration('result', r)\n return r\n }\n\n const local = localCheck(trimmed)\n if (local) {\n const hit = trimmed.match(localBlockRe)?.[0]\n logModeration('local-block', { hit, ...local })\n logModeration('result', local)\n return local\n }\n logModeration('local-pass', { message: '로컬 금칙어 미매칭 → AI Logic 호출' })\n\n if (moderation.enabled === false) {\n const r: ModerationResult = { ...APPROVED, via: 'disabled' }\n logModeration('result', r)\n return r\n }\n\n try {\n const { model, modelName, useLimited } = getModerationModel()\n logModeration('ai-call', { modelName, useLimitedUseAppCheckTokens: useLimited })\n const t0 = performance.now()\n const result = await model.generateContent(`심사할 텍스트:\\n\"\"\"${trimmed.slice(0, 4000)}\"\"\"`)\n const ms = Math.round(performance.now() - t0)\n const raw = result.response.text()\n logModeration('ai-raw', { ms, raw: raw.slice(0, 500) })\n\n const parsed = parseModerationJson(raw)\n if (!parsed) {\n logModeration('ai-parse-fail', { raw: raw.slice(0, 300) })\n const r = errorResult()\n logModeration('result', r)\n return r\n }\n const out: ModerationResult = {\n flagged: parsed.flagged,\n category: parsed.category as ModerationResult['category'],\n reason: parsed.reason,\n via: 'ai',\n }\n logModeration('result', { ...out, ms })\n return out\n }\n catch (e) {\n const err = e as { code?: string, customData?: unknown, cause?: unknown }\n logModeration('ai-error', {\n message: e instanceof Error ? e.message : String(e),\n name: e instanceof Error ? e.name : undefined,\n code: err?.code,\n // 모델 폐기/App Check 등 원인 파악용 (전체 메시지에 이미 포함되는 경우 많음)\n cause: err?.cause instanceof Error ? err.cause.message : err?.cause,\n })\n const r = errorResult()\n logModeration('result', r)\n return r\n }\n }\n\n async function checkImage(file: File): Promise<ModerationResult> {\n if (moderation.enabled === false || !moderation.moderateImages) {\n return { ...APPROVED, via: 'disabled' }\n }\n\n try {\n const base64 = await new Promise<string>((resolve, reject) => {\n const reader = new FileReader()\n reader.onload = () => resolve((reader.result as string).split(',')[1] ?? '')\n reader.onerror = reject\n reader.readAsDataURL(file)\n })\n\n const { model, modelName } = getModerationModel()\n logModeration('ai-image-call', { modelName, type: file.type, size: file.size })\n const result = await model.generateContent([\n { inlineData: { mimeType: file.type || 'image/jpeg', data: base64 } },\n '이 이미지를 심사해 주세요.',\n ])\n const raw = result.response.text()\n const parsed = parseModerationJson(raw)\n if (!parsed) {\n logModeration('ai-image-parse-fail', { raw: raw.slice(0, 300) })\n return errorResult()\n }\n const out: ModerationResult = {\n flagged: parsed.flagged,\n category: parsed.category as ModerationResult['category'],\n reason: parsed.reason,\n via: 'ai',\n }\n logModeration('ai-image-result', out)\n return out\n }\n catch (e) {\n logModeration('ai-image-error', { message: e instanceof Error ? e.message : String(e) })\n return errorResult()\n }\n }\n\n return { checkText, checkImage }\n}\n","import { getStorage, ref as storageRef, uploadBytesResumable, getDownloadURL, deleteObject } from 'firebase/storage'\nimport type { UploadTask } from 'firebase/storage'\nimport { useFirebaseApp } from 'vuefire'\nimport { safeFileName } from '../utils/slugify'\nimport { useMemiBoardConfig } from '../config'\nimport type { Attachment } from '../types'\n\nexport function useMemiBoardStorage() {\n const config = useMemiBoardConfig()\n const app = useFirebaseApp()\n const prefix = () => config.collectionPrefix\n\n /** postId는 아직 저장되지 않은 새 글이면 임시 id(예: 'new-<timestamp>')를 넘겨도 된다 — 경로 네임스페이스일 뿐이다. */\n function uploadAttachment(\n file: File,\n postId: string,\n onProgress?: (ratio: number) => void,\n ): { promise: Promise<Attachment>, cancel: () => void } {\n const storage = getStorage(app)\n const path = `${prefix()}/posts/${postId}/attachments/${Date.now()}-${safeFileName(file.name)}`\n const fileRef = storageRef(storage, path)\n const task: UploadTask = uploadBytesResumable(fileRef, file, {\n contentType: file.type || 'application/octet-stream',\n contentDisposition: `attachment; filename*=UTF-8''${encodeURIComponent(file.name)}`,\n })\n\n const promise = new Promise<Attachment>((resolve, reject) => {\n task.on(\n 'state_changed',\n (snapshot) => {\n if (onProgress && snapshot.totalBytes > 0) {\n onProgress(snapshot.bytesTransferred / snapshot.totalBytes)\n }\n },\n reject,\n async () => {\n const url = await getDownloadURL(fileRef)\n resolve({ name: file.name, url, path, size: file.size, type: file.type })\n },\n )\n })\n\n return { promise, cancel: () => task.cancel() }\n }\n\n async function deleteAttachment(attachment: Pick<Attachment, 'path'>): Promise<void> {\n const storage = getStorage(app)\n await deleteObject(storageRef(storage, attachment.path)).catch(() => {})\n }\n\n return { uploadAttachment, deleteAttachment }\n}\n","import { computed, watch } from 'vue'\nimport { useFirestore, useDocument } from 'vuefire'\nimport { doc, getDoc, setDoc, updateDoc, serverTimestamp } from 'firebase/firestore'\nimport { useMemiBoardConfig } from '../config'\nimport { useMemiBoardAuth } from './useMemiBoardAuth'\nimport { slugify } from '../utils/slugify'\nimport type { BoardCategory, BoardSettingsModel } from '../types'\n\n/** 설정 문서가 없을 때 UI·시드에 쓰는 기본 카테고리. */\nexport const DEFAULT_CATEGORIES: BoardCategory[] = [\n { id: 'free', label: '자유' },\n { id: 'notice', label: '공지' },\n { id: 'question', label: '질문' },\n]\n\n/**\n * 게시판 설정({prefix}Settings/config) — 카테고리 옵션 목록.\n *\n * - 읽기: 전체 공개 (useDocument 실시간)\n * - 문서 없음: DEFAULT 로 보여 주고, 로그인 사용자가 처음 쓰거나 추가할 때 시드 생성\n * - 카테고리 추가: 로그인 사용자 (글쓰기 폼에서 즉시 추가)\n */\nexport function useMemiBoardSettings() {\n const config = useMemiBoardConfig()\n const db = useFirestore()\n const { isSignedIn, isAdmin } = useMemiBoardAuth()\n\n const settingsRef = computed(() => doc(db, `${config.collectionPrefix}Settings`, 'config'))\n const { data: settingsDoc, pending: settingsPending } = useDocument<BoardSettingsModel>(settingsRef)\n\n const categories = computed<BoardCategory[]>(() => {\n const list = settingsDoc.value?.categories\n return list && list.length > 0 ? list : DEFAULT_CATEGORIES\n })\n\n function categoryLabel(id: string | undefined): string | undefined {\n if (!id) return undefined\n return categories.value.find(c => c.id === id)?.label ?? id\n }\n\n /** 설정 문서가 없으면 기본 카테고리로 한 번 생성. 로그인 필요. */\n async function ensureSettings(): Promise<void> {\n if (!isSignedIn.value) return\n const ref = settingsRef.value\n const snap = await getDoc(ref).catch(() => null)\n if (!snap || snap.exists()) return\n await setDoc(ref, {\n categories: DEFAULT_CATEGORIES,\n updatedAt: serverTimestamp(),\n })\n }\n\n /**\n * 카테고리 추가. label 로 id(slug) 생성, 중복 id 면 접미사.\n * @returns 추가된(또는 기존 동일 label 의) category id\n */\n async function addCategory(label: string): Promise<string> {\n const trimmed = label.trim()\n if (!trimmed) throw new Error('카테고리 이름을 입력해 주세요.')\n if (!isSignedIn.value) throw new Error('로그인이 필요합니다.')\n\n await ensureSettings()\n\n const existing = categories.value.find(\n c => c.label === trimmed || c.id === slugify(trimmed),\n )\n if (existing) return existing.id\n\n let id = slugify(trimmed) || `cat-${Date.now()}`\n const used = new Set(categories.value.map(c => c.id))\n if (used.has(id)) {\n let n = 2\n while (used.has(`${id}-${n}`)) n++\n id = `${id}-${n}`\n }\n\n const next: BoardCategory[] = [...categories.value, { id, label: trimmed }]\n const ref = settingsRef.value\n const snap = await getDoc(ref)\n if (snap.exists()) {\n await updateDoc(ref, {\n categories: next,\n updatedAt: serverTimestamp(),\n })\n }\n else {\n await setDoc(ref, {\n categories: next,\n updatedAt: serverTimestamp(),\n })\n }\n return id\n }\n\n // 관리자 방문 시 문서 없으면 시드 (기존 동작 유지 + 로그인 일반 사용자 ensure 와 병행)\n watch(\n [isAdmin, isSignedIn, settingsPending],\n async ([admin, signedIn, pending]) => {\n if (pending || settingsDoc.value) return\n if (!admin && !signedIn) return\n await ensureSettings().catch(() => {})\n },\n { immediate: true },\n )\n\n return {\n categories,\n categoryLabel,\n settingsPending,\n ensureSettings,\n addCategory,\n DEFAULT_CATEGORIES,\n }\n}\n","export interface VersionHistoryEntry {\n version: string\n date: string\n highlights: string[]\n note?: string\n}\n\n/**\n * MemiBoardVersionHistory 컴포넌트가 그대로 보여주는 데이터.\n * 코드 수준의 자세한 변경 내역은 이 레포의 CHANGELOG.md를 참고 — 새 버전을 릴리스할 때 이 배열도 함께 갱신한다.\n */\nexport const versionHistory: VersionHistoryEntry[] = [\n {\n version: '0.2.2',\n date: '2026-07-31',\n highlights: [\n '카테고리 — 글을 주제별로 분류해서 올릴 수 있어요',\n ],\n },\n {\n version: '0.2.0',\n date: '2026-07-31',\n highlights: [\n '글쓰기 / 수정 / 삭제 — 제목, 본문, 태그를 자유롭게 작성',\n '파일 첨부 — 이미지·문서를 게시글에 첨부, 이미지는 미리보기 제공',\n '댓글 — 실시간으로 댓글을 달고 지울 수 있음',\n '로그인 / 권한 — 로그인해야 글을 쓸 수 있고, 본인 글만 수정·삭제 가능(관리자는 전체 관리)',\n 'AI 자동 검열 — 욕설·부적절한 내용이 담긴 글/댓글은 AI가 자동으로 걸러 게시를 막음',\n '한 줄 설정으로 바로 시작 — pages: true 옵션 하나로 게시판 화면이 바로 만들어짐',\n ],\n note: '서버 없이 동작하는 모듈이라, 실제 보안은 Firebase 프로젝트에 규칙 파일을 등록해야 완성됩니다. README의 Security Model 안내를 꼭 따라주세요.',\n },\n {\n version: '0.1.0',\n date: '2026-07-27',\n highlights: [\n '프로젝트 최초 생성 (아직 게시판 기능은 없는 빈 Nuxt 4 프로젝트)',\n ],\n },\n]\n","import type { Timestamp } from 'firebase/firestore'\n\nexport function formatDate(ts: Timestamp | undefined): string {\n return ts?.toDate ? ts.toDate().toLocaleDateString('ko-KR') : ''\n}\n"],"mappings":"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"}
@@ -12,6 +12,7 @@ const { canDeleteComment } = useMemiBoardAuth()
12
12
  const deletingId = ref<string | null>(null)
13
13
 
14
14
  async function handleDelete(commentId: string) {
15
+ if (!window.confirm('이 댓글을 삭제하시겠습니까?')) return
15
16
  deletingId.value = commentId
16
17
  try {
17
18
  await deleteComment(commentId)
@@ -8,8 +8,13 @@ import { useMemiBoardSettings } from 'memi-board'
8
8
  import MemiBoardAttachments from './Attachments.vue'
9
9
 
10
10
  const props = defineProps<{
11
- /** 지정하면 수정 모드, 지정하지 않으면 새 글 작성 모드. */
11
+ /** 지정하면 수정 모드 */
12
12
  postId?: string
13
+ /**
14
+ * path 등으로 카테고리가 이미 정해진 경우 (예: /board/:category/new).
15
+ * 설정되면 선택 UI 없이 이 값으로 저장한다.
16
+ */
17
+ fixedCategory?: string
13
18
  }>()
14
19
 
15
20
  const emit = defineEmits<{ saved: [id: string], cancel: [] }>()
@@ -17,12 +22,12 @@ const emit = defineEmits<{ saved: [id: string], cancel: [] }>()
17
22
  const { user, isSignedIn } = useMemiBoardAuth()
18
23
  const { getPost, createPost, updatePost } = useMemiBoardPosts()
19
24
  const { checkText } = useMemiBoardModeration()
20
- const { categories, ensureSettings, addCategory } = useMemiBoardSettings()
25
+ const { categories, categoryLabel, ensureSettings, addCategory } = useMemiBoardSettings()
21
26
 
22
27
  const title = ref('')
23
28
  const content = ref('')
24
29
  const tagsInput = ref('')
25
- /** boardSettings.categories 의 id */
30
+ /** boardSettings.categories 의 id (fixedCategory 없을 때만 선택) */
26
31
  const category = ref<string | undefined>(undefined)
27
32
  const attachments = ref<Attachment[]>([])
28
33
 
@@ -30,16 +35,48 @@ const categoryItems = computed(() =>
30
35
  categories.value.map(c => ({ label: c.label, value: c.id })),
31
36
  )
32
37
 
38
+ /** 실제 저장에 쓰는 카테고리 id */
39
+ const effectiveCategory = computed(() => props.fixedCategory || category.value)
40
+
33
41
  const isEdit = computed(() => Boolean(props.postId))
42
+ const categoryLocked = computed(() => Boolean(props.fixedCategory))
34
43
  const loading = ref(false)
35
44
  const saving = ref(false)
45
+ /** 제출 중 단계 표시 (로컬 필터 → AI Logic → 저장) */
46
+ const submitHint = ref('')
47
+ /** 마지막 검열 결과 요약 (화면 + 콘솔) */
48
+ const lastModerationDebug = ref('')
36
49
  const error = ref('')
37
50
 
51
+ function formatModerationDebug(m: {
52
+ flagged: boolean
53
+ category: string
54
+ reason: string
55
+ error?: boolean
56
+ via?: string
57
+ }): string {
58
+ const viaLabel: Record<string, string> = {
59
+ empty: '빈 텍스트',
60
+ disabled: '검열 끔',
61
+ local: '로컬 금칙어',
62
+ ai: 'Firebase AI Logic',
63
+ 'ai-error-allow': 'AI 실패 → onError:allow (통과)',
64
+ 'ai-error-block': 'AI 실패 → onError:block (차단)',
65
+ }
66
+ const via = m.via ? (viaLabel[m.via] || m.via) : '?'
67
+ return [
68
+ `via: ${via}`,
69
+ `flagged: ${m.flagged}`,
70
+ `category: ${m.category}`,
71
+ m.reason ? `reason: ${m.reason}` : '',
72
+ m.error ? 'apiError: true' : '',
73
+ ].filter(Boolean).join(' · ')
74
+ }
75
+
38
76
  const showAddCategory = ref(false)
39
77
  const newCategoryLabel = ref('')
40
78
  const addingCategory = ref(false)
41
79
 
42
- /** 새 글은 아직 slug가 없으므로 첨부파일 Storage 경로용 임시 네임스페이스를 쓴다. */
43
80
  const attachmentNamespace = ref(props.postId ?? `new-${Date.now()}`)
44
81
 
45
82
  async function loadPost(id: string) {
@@ -57,7 +94,6 @@ async function loadPost(id: string) {
57
94
  title.value = post.title
58
95
  content.value = post.content
59
96
  tagsInput.value = (post.tags ?? []).join(', ')
60
- // 설정에 없는 예전 카테고리 id 여도 선택값은 유지 (목록에 없으면 USelect 가 비울 수 있음)
61
97
  category.value = post.category
62
98
  attachments.value = post.attachments ?? []
63
99
  attachmentNamespace.value = id
@@ -80,12 +116,21 @@ watch(
80
116
  else {
81
117
  loading.value = false
82
118
  attachmentNamespace.value = `new-${Date.now()}`
119
+ if (props.fixedCategory) category.value = props.fixedCategory
83
120
  void ensureSettings().catch(() => {})
84
121
  }
85
122
  },
86
123
  { immediate: true },
87
124
  )
88
125
 
126
+ watch(
127
+ () => props.fixedCategory,
128
+ (c) => {
129
+ if (c && !props.postId) category.value = c
130
+ },
131
+ { immediate: true },
132
+ )
133
+
89
134
  async function handleAddCategory() {
90
135
  error.value = ''
91
136
  if (!isSignedIn.value) {
@@ -94,7 +139,13 @@ async function handleAddCategory() {
94
139
  }
95
140
  addingCategory.value = true
96
141
  try {
97
- const id = await addCategory(newCategoryLabel.value)
142
+ const label = newCategoryLabel.value.trim()
143
+ const moderation = await checkText(label)
144
+ if (moderation.flagged) {
145
+ error.value = moderation.reason || '사용할 수 없는 카테고리 이름입니다.'
146
+ return
147
+ }
148
+ const id = await addCategory(label)
98
149
  category.value = id
99
150
  newCategoryLabel.value = ''
100
151
  showAddCategory.value = false
@@ -125,30 +176,43 @@ async function handleSubmit() {
125
176
  error.value = '로그인이 필요합니다.'
126
177
  return
127
178
  }
128
- if (!category.value) {
179
+ const cat = effectiveCategory.value
180
+ if (!cat) {
129
181
  error.value = '카테고리를 선택해 주세요.'
130
182
  return
131
183
  }
132
- // 설정에 있는 id 만 허용 (임의 문자열 저장 방지)
133
- if (!categories.value.some(c => c.id === category.value)) {
134
- error.value = '목록에 있는 카테고리를 선택해 주세요. 없으면 아래 + 로 추가할 수 있습니다.'
184
+ if (!categories.value.some(c => c.id === cat) && !props.fixedCategory) {
185
+ error.value = '목록에 있는 카테고리를 선택해 주세요.'
135
186
  return
136
187
  }
137
188
 
138
189
  saving.value = true
190
+ submitHint.value = '내용을 검토하는 중… (로컬 → AI Logic)'
191
+ lastModerationDebug.value = ''
139
192
  try {
140
- const moderation = await checkText(`${title.value}\n${content.value}`)
193
+ const text = `${title.value}\n${content.value}`
194
+ console.info('[memi-board] post checkText 시작', { title: title.value, contentLen: content.value.length })
195
+ const moderation = await checkText(text)
196
+ lastModerationDebug.value = formatModerationDebug(moderation)
197
+ console.info('[memi-board] post checkText 끝', lastModerationDebug.value, moderation)
198
+
141
199
  if (moderation.flagged) {
142
200
  error.value = moderation.reason || '게시할 수 없는 내용이 포함되어 있습니다.'
143
201
  return
144
202
  }
145
203
 
204
+ if (moderation.via === 'ai-error-allow') {
205
+ submitHint.value = 'AI 검열 실패 — 설정상 통과 후 저장…'
206
+ }
207
+ else {
208
+ submitHint.value = '저장하는 중…'
209
+ }
146
210
  const tags = tagsInput.value.split(',').map(t => t.trim()).filter(Boolean)
147
211
  const payload = {
148
212
  title: title.value.trim(),
149
213
  content: content.value,
150
214
  tags,
151
- category: category.value,
215
+ category: cat,
152
216
  attachments: attachments.value,
153
217
  }
154
218
 
@@ -171,6 +235,7 @@ async function handleSubmit() {
171
235
  }
172
236
  finally {
173
237
  saving.value = false
238
+ submitHint.value = ''
174
239
  }
175
240
  }
176
241
  </script>
@@ -189,7 +254,22 @@ async function handleSubmit() {
189
254
  class="flex flex-col gap-4"
190
255
  @submit.prevent="handleSubmit"
191
256
  >
192
- <div class="flex flex-col gap-2">
257
+ <!-- path 로 카테고리 고정 시: 표시만 / 아니면 선택+추가 -->
258
+ <div
259
+ v-if="categoryLocked"
260
+ class="flex items-center gap-2 text-sm"
261
+ >
262
+ <span class="text-muted">카테고리</span>
263
+ <UBadge
264
+ :label="categoryLabel(fixedCategory) || fixedCategory"
265
+ variant="subtle"
266
+ />
267
+ </div>
268
+
269
+ <div
270
+ v-else
271
+ class="flex flex-col gap-2"
272
+ >
193
273
  <div class="flex items-center gap-2 flex-wrap">
194
274
  <USelect
195
275
  v-model="category"
@@ -198,8 +278,8 @@ async function handleSubmit() {
198
278
  class="w-48"
199
279
  />
200
280
  <UButton
201
- type="button"
202
281
  v-if="isSignedIn"
282
+ type="button"
203
283
  variant="outline"
204
284
  color="neutral"
205
285
  size="sm"
@@ -208,10 +288,6 @@ async function handleSubmit() {
208
288
  @click="showAddCategory = !showAddCategory"
209
289
  />
210
290
  </div>
211
- <p class="text-xs text-muted">
212
- 카테고리는 게시판 설정(boardSettings)에 있는 목록에서 고릅니다.
213
- </p>
214
-
215
291
  <div
216
292
  v-if="showAddCategory"
217
293
  class="flex items-center gap-2"
@@ -269,6 +345,18 @@ async function handleSubmit() {
269
345
  >
270
346
  {{ error }}
271
347
  </p>
348
+ <p
349
+ v-else-if="submitHint"
350
+ class="text-sm text-muted"
351
+ >
352
+ {{ submitHint }}
353
+ </p>
354
+ <p
355
+ v-if="lastModerationDebug"
356
+ class="text-xs font-mono text-muted break-all rounded-md bg-muted/40 px-2 py-1.5"
357
+ >
358
+ 검열 디버그: {{ lastModerationDebug }}
359
+ </p>
272
360
 
273
361
  <div class="flex gap-2">
274
362
  <UButton
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { ref, onMounted } from 'vue'
2
+ import { ref, watch } from 'vue'
3
3
  import { RouterLink } from 'vue-router'
4
4
  import type { DocumentData, QueryDocumentSnapshot } from 'firebase/firestore'
5
5
  import { useMemiBoardPosts } from 'memi-board'
@@ -9,7 +9,20 @@ import { formatDate } from 'memi-board'
9
9
 
10
10
  const props = withDefaults(defineProps<{
11
11
  pageSize?: number
12
- /** 지정하면 각 글이 `${linkBase}/${id}`로 이동하는 링크가 된다. 지정하지 않으면 select 이벤트만 emit. */
12
+ /**
13
+ * 글 상세 링크 생성.
14
+ * 예: (p) => `/board/${p.category}/${p.id}`
15
+ * 없으면 postLinkBase / linkBase / select 순.
16
+ */
17
+ getPostLink?: (post: PostModel) => string | undefined
18
+ /**
19
+ * 글 상세 링크 prefix.
20
+ * 예: `/board/post` → `/board/post/{id}`
21
+ */
22
+ postLinkBase?: string
23
+ /** 지정 시 해당 카테고리 글만 조회 */
24
+ category?: string
25
+ /** @deprecated postLinkBase / getPostLink 사용 */
13
26
  linkBase?: string
14
27
  }>(), {
15
28
  pageSize: 20,
@@ -27,11 +40,28 @@ const loading = ref(false)
27
40
  const initialLoading = ref(true)
28
41
  const loadError = ref('')
29
42
 
30
- async function loadMore() {
43
+ function postTo(post: PostModel): string | undefined {
44
+ if (props.getPostLink) return props.getPostLink(post)
45
+ const base = props.postLinkBase || props.linkBase
46
+ if (!base || !post.id) return undefined
47
+ return `${base.replace(/\/$/, '')}/${post.id}`
48
+ }
49
+
50
+ async function loadMore(reset = false) {
31
51
  loading.value = true
32
52
  loadError.value = ''
53
+ if (reset) {
54
+ posts.value = []
55
+ cursor.value = undefined
56
+ hasMore.value = false
57
+ initialLoading.value = true
58
+ }
33
59
  try {
34
- const result = await getPosts({ pageSize: props.pageSize, cursor: cursor.value })
60
+ const result = await getPosts({
61
+ pageSize: props.pageSize,
62
+ cursor: reset ? undefined : cursor.value,
63
+ category: props.category || undefined,
64
+ })
35
65
  posts.value.push(...result.posts)
36
66
  cursor.value = result.cursor
37
67
  hasMore.value = result.hasMore
@@ -46,10 +76,14 @@ async function loadMore() {
46
76
  }
47
77
  }
48
78
 
49
- onMounted(loadMore)
79
+ watch(
80
+ () => props.category,
81
+ () => { void loadMore(true) },
82
+ { immediate: true },
83
+ )
50
84
 
51
85
  function handleClick(post: PostModel) {
52
- if (!props.linkBase) emit('select', post)
86
+ if (!postTo(post)) emit('select', post)
53
87
  }
54
88
  </script>
55
89
 
@@ -78,10 +112,10 @@ function handleClick(post: PostModel) {
78
112
  </p>
79
113
 
80
114
  <component
81
- :is="linkBase ? RouterLink : 'button'"
115
+ :is="postTo(post) ? RouterLink : 'button'"
82
116
  v-for="post in posts"
83
117
  :key="post.id"
84
- :to="linkBase ? `${linkBase}/${post.id}` : undefined"
118
+ :to="postTo(post)"
85
119
  class="text-left"
86
120
  @click="handleClick(post)"
87
121
  >
@@ -128,7 +162,7 @@ function handleClick(post: PostModel) {
128
162
  label="더 보기"
129
163
  block
130
164
  :loading="loading"
131
- @click="loadMore"
165
+ @click="loadMore(false)"
132
166
  />
133
167
  </div>
134
168
  </template>
package/dist/types.d.ts CHANGED
@@ -53,10 +53,14 @@ export interface BoardSettingsModel {
53
53
  categories: BoardCategory[];
54
54
  updatedAt?: Timestamp;
55
55
  }
56
+ /** 검열이 어느 단계에서 끝났는지 (디버그·UI 표시용) */
57
+ export type ModerationVia = 'empty' | 'disabled' | 'local' | 'ai' | 'ai-error-allow' | 'ai-error-block';
56
58
  export interface ModerationResult {
57
59
  flagged: boolean;
58
60
  category: 'none' | 'abuse' | 'spam' | 'adult' | 'violence' | 'other';
59
61
  reason: string;
60
62
  /** true면 API/파싱 실패로 판단이 불확실함을 뜻한다 (onError 옵션에 따라 처리). */
61
63
  error?: boolean;
64
+ /** 로컬 리스트 / AI / 실패 후 allow 등 */
65
+ via?: ModerationVia;
62
66
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memi-board",
3
- "version": "0.4.2",
3
+ "version": "0.4.5",
4
4
  "type": "module",
5
5
  "description": "Firebase 게시판 — core composable + thin Nuxt module(UI SFC). Nuxt 4 + Nuxt UI 호스트에 붙임",
6
6
  "license": "MIT",