memi-board 0.4.6 → 0.4.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,14 +1,14 @@
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, 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 I } from "firebase/storage";
6
- import { GoogleAIBackend as L, Schema as R, getAI as z, getGenerativeModel as ne } 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 ee, writeBatch as w } from "firebase/firestore";
4
+ import { GoogleAuthProvider as T, OAuthProvider as te, createUserWithEmailAndPassword as ne, signInWithEmailAndPassword as re, signInWithPopup as E, signOut as ie, updateProfile as ae } from "firebase/auth";
5
+ import { deleteObject as D, getDownloadURL as O, getStorage as k, listAll as A, ref as j, uploadBytes as M, uploadBytesResumable as oe } from "firebase/storage";
6
+ import { GoogleAIBackend as N, Schema as P, getAI as F, getGenerativeModel as I } from "firebase/ai";
7
7
  //#region src/config.ts
8
- var B = "__MEMI_BOARD_CONFIG__";
9
- function V() {
8
+ var L = "__MEMI_BOARD_CONFIG__";
9
+ function R() {
10
10
  let e = globalThis;
11
- return e[B] || (e[B] = t({
11
+ return e[L] || (e[L] = t({
12
12
  collectionPrefix: "memiBoard",
13
13
  auth: { providers: ["google", "apple"] },
14
14
  moderation: {
@@ -16,113 +16,241 @@ function V() {
16
16
  model: "gemini-3.5-flash-lite",
17
17
  onError: "allow",
18
18
  moderateImages: !1,
19
- localBlocklist: []
19
+ localBlocklist: [],
20
+ blockBanThreshold: 3,
21
+ blockBanDecayMs: 864e5
20
22
  }
21
- })), e[B];
23
+ })), e[L];
22
24
  }
23
- function re(e) {
24
- let t = V();
25
+ function z(e) {
26
+ let t = R();
25
27
  e.collectionPrefix && (t.collectionPrefix = e.collectionPrefix), e.auth && Object.assign(t.auth, e.auth), e.moderation && Object.assign(t.moderation, e.moderation);
26
28
  }
27
- function H() {
28
- return V();
29
+ function B() {
30
+ return R();
31
+ }
32
+ //#endregion
33
+ //#region src/utils/moderation-strike.ts
34
+ var se = 3, V = 864e5;
35
+ function H(e, t, n = Date.now(), r = V) {
36
+ let i = Math.max(0, Math.floor(Number(e) || 0));
37
+ if (i <= 0) return 0;
38
+ let a = typeof t == "number" && Number.isFinite(t) ? t : null;
39
+ if (a == null || n <= a) return i;
40
+ let o = Math.floor((n - a) / r);
41
+ return Math.max(0, i - o);
42
+ }
43
+ function U(e, t, n = Date.now(), r = 3, i = V) {
44
+ let a = Math.max(0, Math.floor(Number(e) || 0));
45
+ if (a < r) return null;
46
+ let o = typeof t == "number" && Number.isFinite(t) ? t : null;
47
+ if (o == null) return n;
48
+ let s = o + (a - (r - 1)) * i;
49
+ return s > n ? s : null;
50
+ }
51
+ function W(e, t, n = Date.now(), r = 3, i = V) {
52
+ return U(e, t, n, r, i) != null;
53
+ }
54
+ function G(e, t = Date.now()) {
55
+ let n = Math.max(0, e - t), r = Math.ceil(n / 36e5);
56
+ if (r <= 0) return "곧 해제";
57
+ if (r < 24) return `약 ${r}시간 후 해제`;
58
+ let i = Math.floor(r / 24), a = r % 24;
59
+ return a === 0 ? `약 ${i}일 후 해제` : `약 ${i}일 ${a}시간 후 해제`;
60
+ }
61
+ function ce(e) {
62
+ if (e == null) return null;
63
+ if (typeof e == "number" && Number.isFinite(e)) return e;
64
+ if (typeof e == "object" && e && "toMillis" in e && typeof e.toMillis == "function") return e.toMillis();
65
+ if (typeof e == "object" && e && "seconds" in e) {
66
+ let t = Number(e.seconds);
67
+ if (Number.isFinite(t)) return t * 1e3;
68
+ }
69
+ return null;
29
70
  }
30
71
  //#endregion
31
72
  //#region src/composables/useMemiBoardAuth.ts
32
- function U() {
33
- let t = H(), i = l(), o = c(), s = a(), u = n("user"), d = n(!0);
34
- function f(e) {
73
+ var K = "__MEMI_BOARD_AUTH_STATE__";
74
+ function le() {
75
+ let e = globalThis;
76
+ return e[K] || (e[K] = {
77
+ role: n("user"),
78
+ rolePending: n(!0),
79
+ rawBlockCount: n(0),
80
+ blockAtMs: n(null),
81
+ watchStarted: !1
82
+ }), e[K];
83
+ }
84
+ function q() {
85
+ let t = B(), n = le(), i = l(), o = c(), s = a(), { role: u, rolePending: d, rawBlockCount: f, blockAtMs: h } = n;
86
+ function _() {
87
+ let e = t.moderation?.blockBanThreshold;
88
+ return e === 0 ? 0 : typeof e == "number" && e > 0 ? Math.floor(e) : 3;
89
+ }
90
+ function v() {
91
+ let e = t.moderation?.blockBanDecayMs;
92
+ return typeof e == "number" && e > 0 ? e : V;
93
+ }
94
+ function y(e) {
35
95
  return p(i, `${t.collectionPrefix}Users`, e);
36
96
  }
37
- async function h(e) {
38
- let t = f(e.uid), n = await m(t);
39
- return n.exists() ? (await C(t, {
97
+ function S(e) {
98
+ if (!e) {
99
+ f.value = 0, h.value = null;
100
+ return;
101
+ }
102
+ f.value = Math.max(0, Math.floor(Number(e.moderationBlockCount) || 0)), h.value = ce(e.moderationBlockAt);
103
+ }
104
+ async function ee(e) {
105
+ let t = y(e.uid), n = await m(t);
106
+ if (!n.exists()) return await x(t, {
107
+ role: "user",
40
108
  displayName: e.displayName,
41
109
  updatedAt: b()
42
- }), n.data().role ?? "user") : (await x(t, {
43
- role: "user",
110
+ }), S({ moderationBlockCount: 0 }), "user";
111
+ let r = n.data();
112
+ return S(r), await C(t, {
44
113
  displayName: e.displayName,
45
114
  updatedAt: b()
46
- }), "user");
115
+ }), r.role ?? "user";
47
116
  }
48
- r(s, async (e) => {
117
+ async function w() {
118
+ let e = s.value?.uid;
119
+ if (!e) {
120
+ S(void 0);
121
+ return;
122
+ }
123
+ let t = await m(y(e));
124
+ S(t.exists() ? t.data() : void 0);
125
+ }
126
+ n.watchStarted || (n.watchStarted = !0, r(s, async (e) => {
49
127
  if (d.value = !0, !e) {
50
- u.value = "user", d.value = !1;
128
+ u.value = "user", S(void 0), d.value = !1;
51
129
  return;
52
130
  }
53
131
  try {
54
- u.value = await h(e);
132
+ u.value = await ee(e);
55
133
  } catch {
56
134
  u.value = "user";
57
135
  } finally {
58
136
  d.value = !1;
59
137
  }
60
- }, { immediate: !0 });
61
- let g = e(() => !!s.value), _ = e(() => u.value === "admin");
62
- function v() {
138
+ }, { immediate: !0 }));
139
+ let D = e(() => !!s.value), O = e(() => u.value === "admin"), k = e(() => H(f.value, h.value, Date.now(), v())), A = e(() => {
140
+ let e = _();
141
+ return e <= 0 ? !1 : W(f.value, h.value, Date.now(), e, v());
142
+ }), j = e(() => {
143
+ let e = _();
144
+ if (e <= 0) return null;
145
+ let t = U(f.value, h.value, Date.now(), e, v());
146
+ return t == null ? null : G(t);
147
+ }), M = e(() => {
148
+ if (!A.value) return null;
149
+ let e = j.value;
150
+ return e ? `콘텐츠 경고가 누적되어 글·댓글 작성이 잠시 제한됐어요. (${e})` : "콘텐츠 경고가 누적되어 글·댓글 작성이 잠시 제한됐어요.";
151
+ });
152
+ async function oe() {
153
+ let e = s.value?.uid, t = _();
154
+ if (!e || t <= 0) return {
155
+ effectiveCount: 0,
156
+ restricted: !1,
157
+ messageSuffix: ""
158
+ };
159
+ let n = y(e), r = await m(n);
160
+ r.exists() ? await C(n, {
161
+ moderationBlockCount: g(1),
162
+ moderationBlockAt: b(),
163
+ updatedAt: b()
164
+ }) : await x(n, {
165
+ role: "user",
166
+ displayName: s.value?.displayName ?? null,
167
+ moderationBlockCount: 1,
168
+ moderationBlockAt: b(),
169
+ updatedAt: b()
170
+ });
171
+ let i = (r.exists() ? Math.max(0, Math.floor(Number(r.data().moderationBlockCount) || 0)) : 0) + 1, a = Date.now();
172
+ f.value = i, h.value = a;
173
+ let o = H(i, a, a, v()), c = W(i, a, a, t, v()), l = ` (경고 ${o}/${t})`;
174
+ if (c) {
175
+ let e = U(i, a, a, t, v()), n = e == null ? null : G(e, a);
176
+ l = n ? ` 경고 ${o}회가 되어 글·댓글 작성이 제한됩니다 (${n}).` : ` 경고 ${o}회가 되어 글·댓글 작성이 제한됩니다.`;
177
+ }
178
+ return {
179
+ effectiveCount: o,
180
+ restricted: c,
181
+ messageSuffix: l
182
+ };
183
+ }
184
+ function N() {
63
185
  if (!o) throw Error("[memi-board] Firebase Auth가 초기화되지 않았습니다.");
64
186
  return o;
65
187
  }
66
- async function y() {
67
- return A(v(), new E());
188
+ async function P() {
189
+ return E(N(), new T());
68
190
  }
69
- async function S() {
70
- let e = new D("apple.com");
71
- return e.addScope("email"), e.addScope("name"), A(v(), e);
191
+ async function F() {
192
+ let e = new te("apple.com");
193
+ return e.addScope("email"), e.addScope("name"), E(N(), e);
72
194
  }
73
- async function w(e, t) {
74
- return k(v(), e, t);
195
+ async function I(e, t) {
196
+ return re(N(), e, t);
75
197
  }
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;
198
+ async function L(e, t, n) {
199
+ let r = await ne(N(), e, t);
200
+ return n && await ae(r.user, { displayName: n }), r;
79
201
  }
80
- async function M() {
81
- await j(v());
202
+ async function R() {
203
+ await ie(N());
82
204
  }
83
- function N(e) {
84
- return g.value && (s.value?.uid === e.authorUid || _.value);
205
+ function z(e) {
206
+ return D.value && (s.value?.uid === e.authorUid || O.value);
85
207
  }
86
- function P(e) {
87
- return g.value && (s.value?.uid === e.authorUid || _.value);
208
+ function se(e) {
209
+ return D.value && (s.value?.uid === e.authorUid || O.value);
88
210
  }
89
211
  return {
90
212
  user: s,
91
- isSignedIn: g,
92
- isAdmin: _,
213
+ isSignedIn: D,
214
+ isAdmin: O,
93
215
  rolePending: d,
94
- signInWithGoogle: y,
95
- signInWithApple: S,
96
- signInWithEmail: w,
97
- signUpWithEmail: T,
98
- signOut: M,
99
- canEdit: N,
100
- canDelete: N,
101
- canDeleteComment: P
216
+ moderationBlockCount: k,
217
+ isWriteRestricted: A,
218
+ restrictedUntilLabel: j,
219
+ restrictedMessage: M,
220
+ signInWithGoogle: P,
221
+ signInWithApple: F,
222
+ signInWithEmail: I,
223
+ signUpWithEmail: L,
224
+ signOut: R,
225
+ canEdit: z,
226
+ canDelete: z,
227
+ canDeleteComment: se,
228
+ recordContentModerationBlock: oe,
229
+ refreshBoardUser: w
102
230
  };
103
231
  }
104
232
  //#endregion
105
233
  //#region src/utils/slugify.ts
106
- function W(e) {
234
+ function J(e) {
107
235
  return e.trim().replace(/\s+/g, "-").replace(/[^a-zA-Z0-9가-힣ㄱ-ㅎㅏ-ㅣ-]/g, "").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
108
236
  }
109
- function G(e, t = 60) {
237
+ function ue(e, t = 60) {
110
238
  return e.replace(/[^a-zA-Z0-9가-힣ㄱ-ㅎ._-]/g, "-").slice(0, t);
111
239
  }
112
240
  //#endregion
113
241
  //#region src/composables/useMemiBoardPosts.ts
114
- async function K(e) {
115
- let t = await te(e).catch(() => ({
242
+ async function de(e) {
243
+ let t = await A(e).catch(() => ({
116
244
  items: [],
117
245
  prefixes: []
118
246
  }));
119
- await Promise.all([...t.items.map((e) => M(e)), ...t.prefixes.map((e) => K(e))]);
247
+ await Promise.all([...t.items.map((e) => D(e)), ...t.prefixes.map((e) => de(e))]);
120
248
  }
121
- function q() {
122
- let e = H(), 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");
249
+ function fe() {
250
+ let e = B(), 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
251
  async function g(e = {}) {
124
252
  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));
253
+ e.category && n.push(ee("category", "==", e.category)), n.push(v("createdAt", "desc")), e.cursor && n.push(S(e.cursor)), n.push(_(t + 1));
126
254
  let r = await h(y(i(), ...n)), a = r.docs.slice(0, t);
127
255
  return {
128
256
  posts: a.map((e) => ({
@@ -133,7 +261,7 @@ function q() {
133
261
  hasMore: r.docs.length > t
134
262
  };
135
263
  }
136
- async function E(e) {
264
+ async function T(e) {
137
265
  let [t, n] = await Promise.all([m(a(e)), m(o(e))]);
138
266
  return t.exists() ? {
139
267
  id: t.id,
@@ -141,8 +269,8 @@ function q() {
141
269
  content: n.exists() ? n.data().content ?? "" : ""
142
270
  } : null;
143
271
  }
144
- async function D(e) {
145
- let t = W(e.title) || "post", n = t, r = 1;
272
+ async function te(e) {
273
+ let t = J(e.title) || "post", n = t, r = 1;
146
274
  for (; (await m(a(n))).exists();) r++, n = `${t}-${r}`;
147
275
  return await Promise.all([x(a(n), {
148
276
  title: e.title,
@@ -159,7 +287,7 @@ function q() {
159
287
  updatedAt: b()
160
288
  }), x(o(n), { content: e.content })]), n;
161
289
  }
162
- async function O(e, t) {
290
+ async function ne(e, t) {
163
291
  await Promise.all([C(a(e), {
164
292
  title: t.title,
165
293
  tags: t.tags ?? [],
@@ -168,29 +296,29 @@ function q() {
168
296
  updatedAt: b()
169
297
  }), x(o(e), { content: t.content }, { merge: !0 })]);
170
298
  }
171
- async function k(e) {
299
+ async function re(e) {
172
300
  let i = await h(c(e));
173
301
  for (let e = 0; e < i.docs.length; e += 450) {
174
- let n = T(t);
302
+ let n = w(t);
175
303
  i.docs.slice(e, e + 450).forEach((e) => n.delete(e.ref)), await n.commit();
176
304
  }
177
- let s = P(n);
178
- await d(o(e)), await K(F(s, `${r()}/posts/${e}`)), await d(a(e));
305
+ let s = k(n);
306
+ await d(o(e)), await de(j(s, `${r()}/posts/${e}`)), await d(a(e));
179
307
  }
180
308
  return {
181
309
  getPosts: g,
182
- getPost: E,
183
- createPost: D,
184
- updatePost: O,
185
- deletePost: k
310
+ getPost: T,
311
+ createPost: te,
312
+ updatePost: ne,
313
+ deletePost: re
186
314
  };
187
315
  }
188
316
  //#endregion
189
317
  //#region src/composables/useMemiBoardComments.ts
190
- function J(t) {
191
- let n = H(), 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);
318
+ function pe(t) {
319
+ let n = B(), 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);
192
320
  async function d(e) {
193
- let t = T(r), n = a(), i = p(u(r, `${n}Posts`, o.value, "comments"));
321
+ let t = w(r), n = a(), i = p(u(r, `${n}Posts`, o.value, "comments"));
194
322
  t.set(i, {
195
323
  postId: o.value,
196
324
  body: e.body,
@@ -202,7 +330,7 @@ function J(t) {
202
330
  }), t.update(p(r, `${n}Posts`, o.value), { commentCount: g(1) }), await t.commit();
203
331
  }
204
332
  async function f(e) {
205
- let t = T(r), n = a();
333
+ let t = w(r), n = a();
206
334
  t.delete(p(r, `${n}Posts`, o.value, "comments", e)), t.update(p(r, `${n}Posts`, o.value), { commentCount: g(-1) }), await t.commit();
207
335
  }
208
336
  return {
@@ -213,7 +341,25 @@ function J(t) {
213
341
  }
214
342
  //#endregion
215
343
  //#region src/utils/moderation-prompt.ts
216
- var Y = "당신은 게시판 콘텐츠를 심사하는 엄격한 모더레이터입니다.\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은 \"\"", ie = [
344
+ var me = "당신은 게시판 콘텐츠를 심사하는 엄격한 모더레이터입니다.\n아래 텍스트에 욕설·비속어·성희롱·혐오·위협·스팸·도배성 광고가 있으면 반드시 거부(flagged=true)합니다.\n\nflagged=true 예:\n- 욕설/비속어: 씨발, 시발, 병신, 지랄, 좆, 개새끼, 존나, fuck, shit, bitch 등\n- 성적 모욕, 혐오 발언, 협박\n- 게시판 주제와 무관한 도배성 광고/스팸\n\nflagged=false:\n- 게시판 주제에 맞는 정상적인 글/댓글 (강한 의견이나 정당한 비판은 허용)\n\nJSON만 출력:\n{\"flagged\":boolean,\"category\":\"none\"|\"abuse\"|\"spam\"|\"adult\"|\"violence\"|\"other\",\"reason\":string}\n- flagged=true면 category를 고르고, reason은 한국어로 **무엇이 문제인지** 구체적으로 1문장\n (예: \"욕설(비속어)이 포함되어 게시할 수 없습니다.\", \"광고·스팸성 문구가 있어 게시할 수 없습니다.\")\n 욕설 단어 전체를 그대로 길게 인용하지 말고, 유형(욕설/혐오/스팸 등)을 분명히 밝힌다.\n- flagged=false면 category는 \"none\", reason은 \"\"", he = {
345
+ abuse: "욕설·비속어·모욕",
346
+ spam: "스팸·광고",
347
+ adult: "선정적·성인 내용",
348
+ violence: "폭력·위협",
349
+ other: "부적절한 내용",
350
+ none: ""
351
+ };
352
+ function Y(e) {
353
+ if (e.via === "local" && e.localHit) {
354
+ let t = e.localHit.trim();
355
+ return t.length > 0 && t.length <= 12 ? `「${t}」 같은 표현은 사용할 수 없습니다. 다른 말로 바꿔 주세요.` : "욕설·비속어가 포함되어 게시할 수 없습니다. 표현을 바꿔 주세요.";
356
+ }
357
+ let t = (e.reason || "").trim();
358
+ if (t) return t;
359
+ let n = e.category && e.category !== "none" ? he[e.category] || e.category : "";
360
+ return n ? `${n}이(가) 포함되어 게시할 수 없습니다. 내용을 수정해 주세요.` : "게시할 수 없는 내용이 포함되어 있습니다. 내용을 수정해 주세요.";
361
+ }
362
+ var ge = [
217
363
  "씨발",
218
364
  "시발",
219
365
  "시이발",
@@ -239,10 +385,10 @@ var Y = "당신은 게시판 콘텐츠를 심사하는 엄격한 모더레이터
239
385
  "bitch",
240
386
  "asshole"
241
387
  ];
242
- function ae(e) {
388
+ function _e(e) {
243
389
  return new RegExp(e.join("|"), "i");
244
390
  }
245
- var oe = /* @__PURE__ */ new Set([
391
+ var ve = /* @__PURE__ */ new Set([
246
392
  "none",
247
393
  "abuse",
248
394
  "spam",
@@ -250,7 +396,7 @@ var oe = /* @__PURE__ */ new Set([
250
396
  "violence",
251
397
  "other"
252
398
  ]);
253
- function X(e) {
399
+ function ye(e) {
254
400
  let t = e.trim();
255
401
  if (!t) return null;
256
402
  let n;
@@ -268,7 +414,7 @@ function X(e) {
268
414
  if (typeof n != "object" || !n) return null;
269
415
  let r = n;
270
416
  if (typeof r.flagged != "boolean") return null;
271
- let i = typeof r.category == "string" && oe.has(r.category) ? r.category : r.flagged ? "other" : "none", a = typeof r.reason == "string" ? r.reason : "";
417
+ let i = typeof r.category == "string" && ve.has(r.category) ? r.category : r.flagged ? "other" : "none", a = typeof r.reason == "string" ? r.reason : "";
272
418
  return {
273
419
  flagged: r.flagged,
274
420
  category: i,
@@ -277,43 +423,48 @@ function X(e) {
277
423
  }
278
424
  //#endregion
279
425
  //#region src/composables/useMemiBoardModeration.ts
280
- var Z = {
426
+ var X = {
281
427
  flagged: !1,
282
428
  category: "none",
283
429
  reason: "",
284
430
  via: "empty"
285
431
  };
286
- function se() {
287
- let e = H().moderation ?? {}, t = s(), n = ae([...ie, ...e.localBlocklist ?? []]);
288
- function r(e) {
289
- return e.match(n) ? {
432
+ function be() {
433
+ let e = B().moderation ?? {}, t = s(), { isWriteRestricted: n, restrictedMessage: r, recordContentModerationBlock: i } = q(), a = _e([...ge, ...e.localBlocklist ?? []]);
434
+ function o(e) {
435
+ let t = e.match(a);
436
+ return t ? {
290
437
  flagged: !0,
291
438
  category: "abuse",
292
- reason: "욕설·비속어가 포함되어 게시할 수 없습니다. 표현을 바꿔 주세요.",
439
+ reason: Y({
440
+ via: "local",
441
+ localHit: t[0] || "",
442
+ category: "abuse"
443
+ }),
293
444
  via: "local"
294
445
  } : null;
295
446
  }
296
- function i() {
297
- let n = e.model ?? "gemini-3.5-flash-lite", r = e.useLimitedUseAppCheckTokens !== !1, i = z(t, {
298
- backend: new L(),
447
+ function c() {
448
+ let n = e.model ?? "gemini-3.5-flash-lite", r = e.useLimitedUseAppCheckTokens !== !1, i = F(t, {
449
+ backend: new N(),
299
450
  useLimitedUseAppCheckTokens: r
300
451
  });
301
- return ne(i, {
452
+ return I(i, {
302
453
  model: n,
303
- systemInstruction: Y,
454
+ systemInstruction: me,
304
455
  generationConfig: {
305
456
  temperature: 0,
306
457
  maxOutputTokens: 256,
307
458
  responseMimeType: "application/json",
308
- responseSchema: R.object({ properties: {
309
- flagged: R.boolean(),
310
- category: R.string(),
311
- reason: R.string()
459
+ responseSchema: P.object({ properties: {
460
+ flagged: P.boolean(),
461
+ category: P.string(),
462
+ reason: P.string()
312
463
  } })
313
464
  }
314
465
  });
315
466
  }
316
- function a() {
467
+ function l() {
317
468
  return e.onError === "block" ? {
318
469
  flagged: !0,
319
470
  category: "other",
@@ -328,64 +479,129 @@ function se() {
328
479
  via: "ai-error-allow"
329
480
  };
330
481
  }
331
- async function o(t) {
332
- let n = t.trim();
333
- if (!n) return {
334
- ...Z,
482
+ function u(e, t = "") {
483
+ let n = Y({
484
+ reason: e.reason,
485
+ category: e.category,
486
+ via: e.via
487
+ }), r = t ? `${n}${t}` : n;
488
+ return {
489
+ ...e,
490
+ reason: r
491
+ };
492
+ }
493
+ async function d(e) {
494
+ if (!e.flagged || e.error || e.via !== "local" && e.via !== "ai") return e;
495
+ try {
496
+ let { messageSuffix: t } = await i();
497
+ return u(e, t);
498
+ } catch (t) {
499
+ return console.warn("[memi-board] record moderation strike failed", t instanceof Error ? t.message : t), u(e);
500
+ }
501
+ }
502
+ async function f(t) {
503
+ if (n.value) return {
504
+ flagged: !0,
505
+ category: "other",
506
+ reason: r.value || "콘텐츠 경고가 누적되어 글·댓글 작성이 잠시 제한됐어요.",
507
+ via: "restricted"
508
+ };
509
+ let i = t.trim();
510
+ if (!i) return {
511
+ ...X,
335
512
  via: "empty"
336
513
  };
337
- let o = r(n);
338
- if (o) return o;
514
+ let a = o(i);
515
+ if (a) return d(a);
339
516
  if (e.enabled === !1) return {
340
- ...Z,
517
+ ...X,
341
518
  via: "disabled"
342
519
  };
343
520
  try {
344
- let e = X((await i().generateContent(`심사할 텍스트:\n"""${n.slice(0, 4e3)}"""`)).response.text());
345
- return e ? {
521
+ let e = ye((await c().generateContent(`심사할 텍스트:\n"""${i.slice(0, 4e3)}"""`)).response.text());
522
+ if (!e) return l();
523
+ let t = {
346
524
  flagged: e.flagged,
347
525
  category: e.category,
348
- reason: e.reason,
526
+ reason: Y({
527
+ reason: e.reason,
528
+ category: e.category,
529
+ via: "ai"
530
+ }),
349
531
  via: "ai"
350
- } : a();
532
+ };
533
+ return t.flagged ? d(t) : t;
351
534
  } catch (e) {
352
- return console.warn("[memi-board] moderation AI failed", e instanceof Error ? e.message : e), a();
535
+ return console.warn("[memi-board] moderation AI failed", e instanceof Error ? e.message : e), l();
353
536
  }
354
537
  }
355
- async function c(t) {
538
+ async function p(t) {
539
+ if (n.value) return {
540
+ flagged: !0,
541
+ category: "other",
542
+ reason: r.value || "콘텐츠 경고가 누적되어 글·댓글 작성이 잠시 제한됐어요.",
543
+ via: "restricted"
544
+ };
356
545
  if (e.enabled === !1 || !e.moderateImages) return {
357
- ...Z,
546
+ ...X,
358
547
  via: "disabled"
359
548
  };
360
549
  try {
361
550
  let e = await new Promise((e, n) => {
362
551
  let r = new FileReader();
363
552
  r.onload = () => e(r.result.split(",")[1] ?? ""), r.onerror = n, r.readAsDataURL(t);
364
- }), n = X((await i().generateContent([{ inlineData: {
553
+ }), n = ye((await c().generateContent([{ inlineData: {
365
554
  mimeType: t.type || "image/jpeg",
366
555
  data: e
367
556
  } }, "이 이미지를 심사해 주세요."])).response.text());
368
- return n ? {
557
+ if (!n) return l();
558
+ let r = {
369
559
  flagged: n.flagged,
370
560
  category: n.category,
371
- reason: n.reason,
561
+ reason: Y({
562
+ reason: n.reason,
563
+ category: n.category,
564
+ via: "ai"
565
+ }),
372
566
  via: "ai"
373
- } : a();
567
+ };
568
+ return r.flagged ? d(r) : r;
374
569
  } catch (e) {
375
- return console.warn("[memi-board] moderation AI image failed", e instanceof Error ? e.message : e), a();
570
+ return console.warn("[memi-board] moderation AI image failed", e instanceof Error ? e.message : e), l();
376
571
  }
377
572
  }
378
573
  return {
379
- checkText: o,
380
- checkImage: c
574
+ checkText: f,
575
+ checkImage: p
381
576
  };
382
577
  }
383
578
  //#endregion
579
+ //#region src/utils/compressImage.ts
580
+ async function xe(e, { maxWidth: t = 400, quality: n = .8 } = {}) {
581
+ return new Promise((r, i) => {
582
+ let a = new Image(), o = URL.createObjectURL(e);
583
+ a.onload = () => {
584
+ URL.revokeObjectURL(o);
585
+ let e = Math.min(1, t / a.naturalWidth), s = document.createElement("canvas");
586
+ s.width = Math.round(a.naturalWidth * e), s.height = Math.round(a.naturalHeight * e);
587
+ let c = s.getContext("2d");
588
+ if (!c) {
589
+ i(/* @__PURE__ */ Error("Canvas를 사용할 수 없습니다"));
590
+ return;
591
+ }
592
+ c.drawImage(a, 0, 0, s.width, s.height), s.toBlob((e) => e ? r(e) : i(/* @__PURE__ */ Error("압축 실패")), "image/jpeg", n);
593
+ }, a.onerror = () => {
594
+ URL.revokeObjectURL(o), i(/* @__PURE__ */ Error("이미지 로드 실패"));
595
+ }, a.src = o;
596
+ });
597
+ }
598
+ //#endregion
384
599
  //#region src/composables/useMemiBoardStorage.ts
385
- function Q() {
386
- let e = H(), t = s(), n = () => e.collectionPrefix;
600
+ var Se = 5242880;
601
+ function Ce() {
602
+ let e = B(), t = s(), n = () => e.collectionPrefix;
387
603
  function r(e, r, i) {
388
- let a = P(t), o = `${n()}/posts/${r}/attachments/${Date.now()}-${G(e.name)}`, s = F(a, o), c = I(s, e, {
604
+ let a = k(t), o = `${n()}/posts/${r}/attachments/${Date.now()}-${ue(e.name)}`, s = j(a, o), c = oe(s, e, {
389
605
  contentType: e.type || "application/octet-stream",
390
606
  contentDisposition: `attachment; filename*=UTF-8''${encodeURIComponent(e.name)}`
391
607
  });
@@ -394,7 +610,7 @@ function Q() {
394
610
  c.on("state_changed", (e) => {
395
611
  i && e.totalBytes > 0 && i(e.bytesTransferred / e.totalBytes);
396
612
  }, n, async () => {
397
- let n = await N(s);
613
+ let n = await O(s);
398
614
  t({
399
615
  name: e.name,
400
616
  url: n,
@@ -408,17 +624,39 @@ function Q() {
408
624
  };
409
625
  }
410
626
  async function i(e) {
411
- let n = P(t);
412
- await M(F(n, e.path)).catch(() => {});
627
+ let n = k(t);
628
+ await D(j(n, e.path)).catch(() => {});
629
+ }
630
+ async function a(e, r) {
631
+ if (!e.type.startsWith("image/")) throw Error("이미지 파일만 업로드할 수 있습니다.");
632
+ if (e.size > 5242880) throw Error(`이미지는 ${Se / 1024 / 1024}MB 이하여야 합니다.`);
633
+ let i = k(t), a = (e.name.split(".").pop() || e.type.split("/")[1] || "png").replace(/[^a-zA-Z0-9]/g, "").slice(0, 8) || "png", o = ue(e.name.replace(/\.[^.]+$/, "") || "image", 40), s = `${Date.now()}-${o || "image"}`, c = r || `new-${Date.now()}`, l = `${n()}/posts/${c}/images/${s}.${a}`, u = j(i, l);
634
+ await M(u, e, { contentType: e.type || "image/png" });
635
+ let d = await O(u), f = await xe(e, {
636
+ maxWidth: 400,
637
+ quality: .8
638
+ }), p = `${n()}/posts/${c}/images/thumbnails/${s}.jpg`, m = j(i, p);
639
+ return await M(m, f, { contentType: "image/jpeg" }), {
640
+ originalUrl: d,
641
+ originalPath: l,
642
+ thumbnailUrl: await O(m),
643
+ thumbnailPath: p
644
+ };
645
+ }
646
+ async function o(e) {
647
+ let n = k(t);
648
+ await Promise.all([D(j(n, e.originalPath)).catch(() => {}), D(j(n, e.thumbnailPath)).catch(() => {})]);
413
649
  }
414
650
  return {
415
651
  uploadAttachment: r,
416
- deleteAttachment: i
652
+ deleteAttachment: i,
653
+ uploadEditorImage: a,
654
+ deleteEditorImage: o
417
655
  };
418
656
  }
419
657
  //#endregion
420
658
  //#region src/composables/useMemiBoardSettings.ts
421
- var $ = [
659
+ var Z = [
422
660
  {
423
661
  id: "free",
424
662
  label: "자유"
@@ -432,10 +670,10 @@ var $ = [
432
670
  label: "질문"
433
671
  }
434
672
  ];
435
- function ce() {
436
- let t = H(), n = l(), { isSignedIn: i, isAdmin: a } = U(), s = e(() => p(n, `${t.collectionPrefix}Settings`, "config")), { data: c, pending: u } = o(s), d = e(() => {
673
+ function we() {
674
+ let t = B(), n = l(), { isSignedIn: i, isAdmin: a } = q(), s = e(() => p(n, `${t.collectionPrefix}Settings`, "config")), { data: c, pending: u } = o(s), d = e(() => {
437
675
  let e = c.value?.categories;
438
- return e && e.length > 0 ? e : $;
676
+ return e && e.length > 0 ? e : Z;
439
677
  });
440
678
  function f(e) {
441
679
  if (e) return d.value.find((t) => t.id === e)?.label ?? e;
@@ -444,7 +682,7 @@ function ce() {
444
682
  if (!i.value) return;
445
683
  let e = s.value, t = await m(e).catch(() => null);
446
684
  !t || t.exists() || await x(e, {
447
- categories: $,
685
+ categories: Z,
448
686
  updatedAt: b()
449
687
  });
450
688
  }
@@ -453,9 +691,9 @@ function ce() {
453
691
  if (!t) throw Error("카테고리 이름을 입력해 주세요.");
454
692
  if (!i.value) throw Error("로그인이 필요합니다.");
455
693
  await h();
456
- let n = d.value.find((e) => e.label === t || e.id === W(t));
694
+ let n = d.value.find((e) => e.label === t || e.id === J(t));
457
695
  if (n) return n.id;
458
- let r = W(t) || `cat-${Date.now()}`, a = new Set(d.value.map((e) => e.id));
696
+ let r = J(t) || `cat-${Date.now()}`, a = new Set(d.value.map((e) => e.id));
459
697
  if (a.has(r)) {
460
698
  let e = 2;
461
699
  for (; a.has(`${r}-${e}`);) e++;
@@ -485,12 +723,12 @@ function ce() {
485
723
  settingsPending: u,
486
724
  ensureSettings: h,
487
725
  addCategory: g,
488
- DEFAULT_CATEGORIES: $
726
+ DEFAULT_CATEGORIES: Z
489
727
  };
490
728
  }
491
729
  //#endregion
492
730
  //#region src/data/versionHistory.ts
493
- var le = [
731
+ var Te = [
494
732
  {
495
733
  version: "0.2.2",
496
734
  date: "2026-07-31",
@@ -517,10 +755,92 @@ var le = [
517
755
  ];
518
756
  //#endregion
519
757
  //#region src/utils/formatDate.ts
520
- function ue(e) {
758
+ function Ee(e) {
521
759
  return e?.toDate ? e.toDate().toLocaleDateString("ko-KR") : "";
522
760
  }
523
761
  //#endregion
524
- export { $ as DEFAULT_CATEGORIES, re as configureMemiBoard, ue as formatDate, U as useMemiBoardAuth, J as useMemiBoardComments, H as useMemiBoardConfig, se as useMemiBoardModeration, q as useMemiBoardPosts, ce as useMemiBoardSettings, Q as useMemiBoardStorage, le as versionHistory };
762
+ //#region src/utils/extractEditorImageUrls.ts
763
+ function De(e) {
764
+ if (!e) return [];
765
+ let t = /* @__PURE__ */ new Set();
766
+ for (let n of e.matchAll(/!\[[^\]]*]\((https?:\/\/[^)\s]+)\)/g)) n[1] && t.add(n[1].replaceAll("&amp;", "&"));
767
+ for (let n of e.matchAll(/<img[^>]*\ssrc=["'](https?:\/\/[^"']+)["']/gi)) n[1] && t.add(n[1].replaceAll("&amp;", "&"));
768
+ return [...t];
769
+ }
770
+ //#endregion
771
+ //#region src/utils/renderMarkdown.ts
772
+ function Q(e) {
773
+ return e.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
774
+ }
775
+ function $(e) {
776
+ let t = e;
777
+ return t = t.replace(/`([^`]+)`/g, "<code>$1</code>"), t = t.replace(/!\[([^\]]*)\]\((https?:\/\/[^)\s]+)\)/g, "<img src=\"$2\" alt=\"$1\" class=\"max-w-full h-auto rounded-lg my-3\" loading=\"lazy\" />"), t = t.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, "<a href=\"$2\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-primary underline\">$1</a>"), t = t.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>"), t = t.replace(/\*([^*]+)\*/g, "<em>$1</em>"), t = t.replace(/~~([^~]+)~~/g, "<del>$1</del>"), t;
778
+ }
779
+ function Oe(e) {
780
+ if (!e?.trim()) return "";
781
+ let t = e.replace(/\r\n/g, "\n").split("\n"), n = [], r = 0, i = !1, a = [], o = null, s = [];
782
+ function c() {
783
+ if (!o || !s.length) {
784
+ o = null, s = [];
785
+ return;
786
+ }
787
+ let e = o;
788
+ n.push(`<${e}>${s.map((e) => `<li>${$(e)}</li>`).join("")}</${e}>`), o = null, s = [];
789
+ }
790
+ for (; r < t.length;) {
791
+ let e = t[r] ?? "", l = e.match(/^```(\w*)\s*$/);
792
+ if (l) {
793
+ i ? (n.push(`<pre class="overflow-x-auto rounded-lg bg-muted p-3 text-sm"><code>${a.join("\n")}</code></pre>`), a = [], i = !1) : (c(), i = !0, l[1]), r++;
794
+ continue;
795
+ }
796
+ if (i) {
797
+ a.push(Q(e)), r++;
798
+ continue;
799
+ }
800
+ if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(e)) {
801
+ c(), n.push("<hr class=\"my-4 border-default\" />"), r++;
802
+ continue;
803
+ }
804
+ let u = e.match(/^(#{1,3})\s+(.+)$/);
805
+ if (u) {
806
+ c();
807
+ let e = u[1].length;
808
+ n.push(`<h${e} class="font-semibold mt-4 mb-2">${$(Q(u[2]))}</h${e}>`), r++;
809
+ continue;
810
+ }
811
+ if (e.startsWith("> ")) {
812
+ c();
813
+ let e = [];
814
+ for (; r < t.length && (t[r] ?? "").startsWith("> ");) e.push(Q((t[r] ?? "").slice(2))), r++;
815
+ n.push(`<blockquote class="border-l-4 border-default pl-3 text-muted my-3">${$(e.join("<br />"))}</blockquote>`);
816
+ continue;
817
+ }
818
+ let d = e.match(/^[-*+]\s+(.+)$/);
819
+ if (d) {
820
+ o && o !== "ul" && c(), o = "ul", s.push(Q(d[1])), r++;
821
+ continue;
822
+ }
823
+ let f = e.match(/^\d+\.\s+(.+)$/);
824
+ if (f) {
825
+ o && o !== "ol" && c(), o = "ol", s.push(Q(f[1])), r++;
826
+ continue;
827
+ }
828
+ if (!e.trim()) {
829
+ c(), r++;
830
+ continue;
831
+ }
832
+ c();
833
+ let p = [];
834
+ for (; r < t.length;) {
835
+ let e = t[r] ?? "";
836
+ if (!e.trim() || /^(#{1,3}\s|```|[-*+]\s|\d+\.\s|> |(-{3,}|\*{3,})\s*$)/.test(e)) break;
837
+ p.push(Q(e)), r++;
838
+ }
839
+ n.push(`<p class="my-2 leading-relaxed">${$(p.join("<br />"))}</p>`);
840
+ }
841
+ return c(), i && a.length && n.push(`<pre class="overflow-x-auto rounded-lg bg-muted p-3 text-sm"><code>${a.join("\n")}</code></pre>`), n.join("\n");
842
+ }
843
+ //#endregion
844
+ export { V as DEFAULT_BLOCK_BAN_DECAY_MS, se as DEFAULT_BLOCK_BAN_THRESHOLD, Z as DEFAULT_CATEGORIES, Se as EDITOR_IMAGE_MAX_BYTES, xe as compressImage, z as configureMemiBoard, H as effectiveModerationBlockCount, De as extractEditorImageUrls, Ee as formatDate, G as formatRestrictedUntilLabel, W as isModerationWriteRestricted, U as moderationWriteRestrictedUntilMs, Oe as renderMarkdownToHtml, q as useMemiBoardAuth, pe as useMemiBoardComments, B as useMemiBoardConfig, be as useMemiBoardModeration, fe as useMemiBoardPosts, we as useMemiBoardSettings, Ce as useMemiBoardStorage, Te as versionHistory };
525
845
 
526
846
  //# sourceMappingURL=index.js.map