memi-board 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js 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, useFirebaseApp as o, useFirebaseAuth as s, useFirestore as c } from "vuefire";
3
3
  import { collection as l, deleteDoc as u, deleteField as d, doc as f, endBefore as p, getDoc as m, getDocs as h, increment as g, limit as _, limitToLast as v, orderBy as y, query as b, serverTimestamp as x, setDoc as S, startAfter as C, updateDoc as w, where as T, writeBatch as E } from "firebase/firestore";
4
- import { GoogleAuthProvider as D, OAuthProvider as ee, createUserWithEmailAndPassword as O, signInWithEmailAndPassword as te, signInWithPopup as k, signOut as ne, updateProfile as re } from "firebase/auth";
5
- import { deleteObject as A, getDownloadURL as j, getStorage as M, listAll as N, ref as P, uploadBytes as ie, uploadBytesResumable as F } from "firebase/storage";
6
- import { GoogleAIBackend as ae, Schema as I, getAI as oe, getGenerativeModel as se } from "firebase/ai";
4
+ import { GoogleAuthProvider as D, OAuthProvider as ee, createUserWithEmailAndPassword as O, signInWithEmailAndPassword as te, signInWithPopup as ne, signOut as re, updateProfile as ie } from "firebase/auth";
5
+ import { deleteObject as k, getDownloadURL as A, getStorage as j, listAll as ae, ref as M, uploadBytes as N, uploadBytesResumable as oe } from "firebase/storage";
6
+ import { GoogleAIBackend as se, Schema as P, getAI as ce, getGenerativeModel as le } from "firebase/ai";
7
7
  //#region src/config.ts
8
- var L = "__MEMI_BOARD_CONFIG__";
9
- function R() {
8
+ var F = "__MEMI_BOARD_CONFIG__";
9
+ function I() {
10
10
  let e = globalThis;
11
- return e[L] || (e[L] = t({
11
+ return e[F] || (e[F] = t({
12
12
  collectionPrefix: "memiBoard",
13
13
  auth: { providers: ["google", "apple"] },
14
14
  moderation: {
@@ -20,19 +20,19 @@ function R() {
20
20
  blockBanThreshold: 3,
21
21
  blockBanDecayMs: 864e5
22
22
  }
23
- })), e[L];
23
+ })), e[F];
24
24
  }
25
- function ce(e) {
26
- let t = R();
25
+ function ue(e) {
26
+ let t = I();
27
27
  e.collectionPrefix && (t.collectionPrefix = e.collectionPrefix), e.auth && Object.assign(t.auth, e.auth), e.moderation && Object.assign(t.moderation, e.moderation);
28
28
  }
29
- function z() {
30
- return R();
29
+ function L() {
30
+ return I();
31
31
  }
32
32
  //#endregion
33
33
  //#region src/utils/moderation-strike.ts
34
- var le = 3, B = 864e5;
35
- function V(e, t, n = Date.now(), r = B) {
34
+ var R = 3, z = 864e5;
35
+ function B(e, t, n = Date.now(), r = z) {
36
36
  let i = Math.max(0, Math.floor(Number(e) || 0));
37
37
  if (i <= 0) return 0;
38
38
  let a = typeof t == "number" && Number.isFinite(t) ? t : null;
@@ -40,7 +40,7 @@ function V(e, t, n = Date.now(), r = B) {
40
40
  let o = Math.floor((n - a) / r);
41
41
  return Math.max(0, i - o);
42
42
  }
43
- function H(e, t, n = Date.now(), r = 3, i = B) {
43
+ function V(e, t, n = Date.now(), r = 3, i = z) {
44
44
  let a = Math.max(0, Math.floor(Number(e) || 0));
45
45
  if (a < r) return null;
46
46
  let o = typeof t == "number" && Number.isFinite(t) ? t : null;
@@ -48,17 +48,17 @@ function H(e, t, n = Date.now(), r = 3, i = B) {
48
48
  let s = o + (a - (r - 1)) * i;
49
49
  return s > n ? s : null;
50
50
  }
51
- function U(e, t, n = Date.now(), r = 3, i = B) {
52
- return H(e, t, n, r, i) != null;
51
+ function H(e, t, n = Date.now(), r = 3, i = z) {
52
+ return V(e, t, n, r, i) != null;
53
53
  }
54
- function W(e, t = Date.now()) {
54
+ function U(e, t = Date.now()) {
55
55
  let n = Math.max(0, e - t), r = Math.ceil(n / 36e5);
56
56
  if (r <= 0) return "곧 해제";
57
57
  if (r < 24) return `약 ${r}시간 후 해제`;
58
58
  let i = Math.floor(r / 24), a = r % 24;
59
59
  return a === 0 ? `약 ${i}일 후 해제` : `약 ${i}일 ${a}시간 후 해제`;
60
60
  }
61
- function ue(e) {
61
+ function de(e) {
62
62
  if (e == null) return null;
63
63
  if (typeof e == "number" && Number.isFinite(e)) return e;
64
64
  if (typeof e == "object" && e && "toMillis" in e && typeof e.toMillis == "function") return e.toMillis();
@@ -70,26 +70,26 @@ function ue(e) {
70
70
  }
71
71
  //#endregion
72
72
  //#region src/composables/useMemiBoardAuth.ts
73
- var G = "__MEMI_BOARD_AUTH_STATE__";
74
- function de() {
73
+ var W = "__MEMI_BOARD_AUTH_STATE__";
74
+ function fe() {
75
75
  let e = globalThis;
76
- return e[G] || (e[G] = {
76
+ return e[W] || (e[W] = {
77
77
  role: n("user"),
78
78
  rolePending: n(!0),
79
79
  rawBlockCount: n(0),
80
80
  blockAtMs: n(null),
81
81
  watchStarted: !1
82
- }), e[G];
82
+ }), e[W];
83
83
  }
84
- function K() {
85
- let t = z(), n = de(), i = c(), o = s(), l = a(), { role: u, rolePending: d, rawBlockCount: p, blockAtMs: h } = n;
84
+ function G() {
85
+ let t = L(), n = fe(), i = c(), o = s(), l = a(), { role: u, rolePending: d, rawBlockCount: p, blockAtMs: h } = n;
86
86
  function _() {
87
87
  let e = t.moderation?.blockBanThreshold;
88
88
  return e === 0 ? 0 : typeof e == "number" && e > 0 ? Math.floor(e) : 3;
89
89
  }
90
90
  function v() {
91
91
  let e = t.moderation?.blockBanDecayMs;
92
- return typeof e == "number" && e > 0 ? e : B;
92
+ return typeof e == "number" && e > 0 ? e : z;
93
93
  }
94
94
  function y(e) {
95
95
  return f(i, `${t.collectionPrefix}Users`, e);
@@ -99,20 +99,28 @@ function K() {
99
99
  p.value = 0, h.value = null;
100
100
  return;
101
101
  }
102
- p.value = Math.max(0, Math.floor(Number(e.moderationBlockCount) || 0)), h.value = ue(e.moderationBlockAt);
102
+ p.value = Math.max(0, Math.floor(Number(e.moderationBlockCount) || 0)), h.value = de(e.moderationBlockAt);
103
103
  }
104
104
  async function C(e) {
105
105
  let t = y(e.uid), n = await m(t);
106
106
  if (!n.exists()) return await S(t, {
107
107
  role: "user",
108
108
  displayName: e.displayName,
109
+ email: e.email,
110
+ photoURL: e.photoURL,
109
111
  updatedAt: x()
110
112
  }), b({ moderationBlockCount: 0 }), "user";
111
113
  let r = n.data();
112
114
  return b(r), await w(t, {
113
115
  displayName: e.displayName,
116
+ email: e.email,
117
+ photoURL: e.photoURL,
114
118
  updatedAt: x()
115
- }), r.role ?? "user";
119
+ }), [
120
+ "admin",
121
+ "staff",
122
+ "user"
123
+ ].includes(String(r.role)) ? r.role : "user";
116
124
  }
117
125
  async function T() {
118
126
  let e = l.value?.uid;
@@ -136,20 +144,20 @@ function K() {
136
144
  d.value = !1;
137
145
  }
138
146
  }, { immediate: !0 }));
139
- let E = e(() => !!l.value), A = e(() => u.value === "admin"), j = e(() => V(p.value, h.value, Date.now(), v())), M = e(() => {
147
+ let E = e(() => !!l.value), k = e(() => u.value === "admin"), A = e(() => u.value === "staff"), j = e(() => k.value || A.value), ae = e(() => B(p.value, h.value, Date.now(), v())), M = e(() => {
140
148
  let e = _();
141
- return e <= 0 ? !1 : U(p.value, h.value, Date.now(), e, v());
149
+ return e <= 0 ? !1 : H(p.value, h.value, Date.now(), e, v());
142
150
  }), N = e(() => {
143
151
  let e = _();
144
152
  if (e <= 0) return null;
145
- let t = H(p.value, h.value, Date.now(), e, v());
146
- return t == null ? null : W(t);
147
- }), P = e(() => {
153
+ let t = V(p.value, h.value, Date.now(), e, v());
154
+ return t == null ? null : U(t);
155
+ }), oe = e(() => {
148
156
  if (!M.value) return null;
149
157
  let e = N.value;
150
158
  return e ? `콘텐츠 경고가 누적되어 글·댓글 작성이 잠시 제한됐어요. (${e})` : "콘텐츠 경고가 누적되어 글·댓글 작성이 잠시 제한됐어요.";
151
159
  });
152
- async function ie() {
160
+ async function se() {
153
161
  let e = l.value?.uid, t = _();
154
162
  if (!e || t <= 0) return {
155
163
  effectiveCount: 0,
@@ -170,9 +178,9 @@ function K() {
170
178
  });
171
179
  let i = (r.exists() ? Math.max(0, Math.floor(Number(r.data().moderationBlockCount) || 0)) : 0) + 1, a = Date.now();
172
180
  p.value = i, h.value = a;
173
- let o = V(i, a, a, v()), s = U(i, a, a, t, v()), c = ` (경고 ${o}/${t})`;
181
+ let o = B(i, a, a, v()), s = H(i, a, a, t, v()), c = ` (경고 ${o}/${t})`;
174
182
  if (s) {
175
- let e = H(i, a, a, t, v()), n = e == null ? null : W(e, a);
183
+ let e = V(i, a, a, t, v()), n = e == null ? null : U(e, a);
176
184
  c = n ? ` 경고 ${o}회가 되어 글·댓글 작성이 제한됩니다 (${n}).` : ` 경고 ${o}회가 되어 글·댓글 작성이 제한됩니다.`;
177
185
  }
178
186
  return {
@@ -181,65 +189,67 @@ function K() {
181
189
  messageSuffix: c
182
190
  };
183
191
  }
184
- function F() {
192
+ function P() {
185
193
  if (!o) throw Error("[memi-board] Firebase Auth가 초기화되지 않았습니다.");
186
194
  return o;
187
195
  }
188
- async function ae() {
189
- return k(F(), new D());
196
+ async function ce() {
197
+ return ne(P(), new D());
190
198
  }
191
- async function I() {
199
+ async function le() {
192
200
  let e = new ee("apple.com");
193
- return e.addScope("email"), e.addScope("name"), k(F(), e);
201
+ return e.addScope("email"), e.addScope("name"), ne(P(), e);
194
202
  }
195
- async function oe(e, t) {
196
- return te(F(), e, t);
203
+ async function F(e, t) {
204
+ return te(P(), e, t);
197
205
  }
198
- async function se(e, t, n) {
199
- let r = await O(F(), e, t);
200
- return n && await re(r.user, { displayName: n }), r;
206
+ async function I(e, t, n) {
207
+ let r = await O(P(), e, t);
208
+ return n && await ie(r.user, { displayName: n }), r;
201
209
  }
202
- async function L() {
203
- await ne(F());
210
+ async function ue() {
211
+ await re(P());
204
212
  }
205
213
  function R(e) {
206
- return E.value && (l.value?.uid === e.authorUid || A.value);
214
+ return E.value && (l.value?.uid === e.authorUid || j.value);
207
215
  }
208
- function ce(e) {
209
- return E.value && (l.value?.uid === e.authorUid || A.value);
216
+ function W(e) {
217
+ return E.value && (l.value?.uid === e.authorUid || j.value);
210
218
  }
211
219
  return {
212
220
  user: l,
213
221
  isSignedIn: E,
214
- isAdmin: A,
222
+ isAdmin: k,
223
+ isStaff: A,
224
+ canManageContent: j,
215
225
  rolePending: d,
216
- moderationBlockCount: j,
226
+ moderationBlockCount: ae,
217
227
  isWriteRestricted: M,
218
228
  restrictedUntilLabel: N,
219
- restrictedMessage: P,
220
- signInWithGoogle: ae,
221
- signInWithApple: I,
222
- signInWithEmail: oe,
223
- signUpWithEmail: se,
224
- signOut: L,
229
+ restrictedMessage: oe,
230
+ signInWithGoogle: ce,
231
+ signInWithApple: le,
232
+ signInWithEmail: F,
233
+ signUpWithEmail: I,
234
+ signOut: ue,
225
235
  canEdit: R,
226
236
  canDelete: R,
227
- canDeleteComment: ce,
228
- recordContentModerationBlock: ie,
237
+ canDeleteComment: W,
238
+ recordContentModerationBlock: se,
229
239
  refreshBoardUser: T
230
240
  };
231
241
  }
232
242
  //#endregion
233
243
  //#region src/utils/slugify.ts
234
- function q(e) {
244
+ function K(e) {
235
245
  return e.trim().replace(/\s+/g, "-").replace(/[^a-zA-Z0-9가-힣ㄱ-ㅎㅏ-ㅣ-]/g, "").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
236
246
  }
237
- function fe(e, t = 60) {
247
+ function pe(e, t = 60) {
238
248
  return e.replace(/[^a-zA-Z0-9가-힣ㄱ-ㅎ._-]/g, "-").slice(0, t);
239
249
  }
240
250
  //#endregion
241
251
  //#region src/utils/extractEditorImageUrls.ts
242
- function pe(e) {
252
+ function me(e) {
243
253
  if (!e) return [];
244
254
  let t = /* @__PURE__ */ new Set();
245
255
  for (let n of e.matchAll(/!\[[^\]]*]\((https?:\/\/[^)\s]+)\)/g)) n[1] && t.add(n[1].replaceAll("&amp;", "&"));
@@ -248,7 +258,7 @@ function pe(e) {
248
258
  }
249
259
  //#endregion
250
260
  //#region src/utils/storagePath.ts
251
- function me(e) {
261
+ function he(e) {
252
262
  try {
253
263
  let t = e.match(/\/o\/([^?]+)/);
254
264
  return t?.[1] ? decodeURIComponent(t[1]) : null;
@@ -256,25 +266,25 @@ function me(e) {
256
266
  return null;
257
267
  }
258
268
  }
259
- function J(e) {
269
+ function q(e) {
260
270
  return e.match(/(?:^|\/)posts\/([^/]+)\//)?.[1] ?? null;
261
271
  }
262
272
  //#endregion
263
273
  //#region src/utils/postPreview.ts
264
- function Y(e) {
274
+ function J(e) {
265
275
  return e.replace(/&amp;/g, "&").replace(/&quot;/g, "\"").replace(/&#39;/g, "'");
266
276
  }
267
- function he(e) {
277
+ function ge(e) {
268
278
  try {
269
- let t = new URL(Y(e)), n = t.hostname.toLowerCase().replace(/^www\./, "");
279
+ let t = new URL(J(e)), n = t.hostname.toLowerCase().replace(/^www\./, "");
270
280
  if (n === "youtu.be") return t.pathname.split("/").filter(Boolean)[0];
271
281
  if (n.endsWith("youtube.com") || n === "youtube-nocookie.com") return t.pathname.startsWith("/embed/") || t.pathname.startsWith("/shorts/") ? t.pathname.split("/")[2] : t.searchParams.get("v") || void 0;
272
282
  } catch {
273
283
  return;
274
284
  }
275
285
  }
276
- function ge(e, t = []) {
277
- let n = e.match(/<img\b[^>]*\bsrc=["']([^"']+)["']/i), r = e.match(/<iframe\b[^>]*\bsrc=["']([^"']+)["']/i), i = t.find((e) => e.type.startsWith("image/"))?.url, a = t.find((e) => e.type.startsWith("video/"))?.url, o = r ? Y(r[1]) : a, s = o ? he(o) : void 0, c = n ? Y(n[1]) : i || (s ? `https://i.ytimg.com/vi/${s}/hqdefault.jpg` : void 0);
286
+ function _e(e, t = []) {
287
+ let n = e.match(/<img\b[^>]*\bsrc=["']([^"']+)["']/i), r = e.match(/<iframe\b[^>]*\bsrc=["']([^"']+)["']/i), i = t.find((e) => e.type.startsWith("image/"))?.url, a = t.find((e) => e.type.startsWith("video/"))?.url, o = r ? J(r[1]) : a, s = o ? ge(o) : void 0, c = n ? J(n[1]) : i || (s ? `https://i.ytimg.com/vi/${s}/hqdefault.jpg` : void 0);
278
288
  return {
279
289
  summary: e.replace(/<style[\s\S]*?<\/style>/gi, " ").replace(/<script[\s\S]*?<\/script>/gi, " ").replace(/<[^>]+>/g, " ").replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/\s+/g, " ").trim().slice(0, 160),
280
290
  ...c ? { previewImage: c } : {},
@@ -283,19 +293,19 @@ function ge(e, t = []) {
283
293
  }
284
294
  //#endregion
285
295
  //#region src/composables/useMemiBoardPosts.ts
286
- async function X(e) {
287
- let t = await N(e).catch(() => ({
296
+ async function Y(e) {
297
+ let t = await ae(e).catch(() => ({
288
298
  items: [],
289
299
  prefixes: []
290
300
  }));
291
- await Promise.all([...t.items.map((e) => A(e)), ...t.prefixes.map((e) => X(e))]);
301
+ await Promise.all([...t.items.map((e) => k(e)), ...t.prefixes.map((e) => Y(e))]);
292
302
  }
293
- async function _e(e, t) {
303
+ async function ve(e, t) {
294
304
  let n = [...new Set([...t].filter(Boolean))];
295
- await Promise.all(n.map((t) => A(P(e, t)).catch(() => {})));
305
+ await Promise.all(n.map((t) => k(M(e, t)).catch(() => {})));
296
306
  }
297
- function ve() {
298
- let e = z(), t = c(), n = o(), r = () => e.collectionPrefix, i = () => l(t, `${r()}Posts`), a = (e) => f(t, `${r()}Posts`, e), s = (e) => f(t, `${r()}Posts`, e, "body", "main"), g = (e) => l(t, `${r()}Posts`, e, "comments");
307
+ function ye() {
308
+ let e = L(), t = c(), n = o(), r = () => e.collectionPrefix, i = () => l(t, `${r()}Posts`), a = (e) => f(t, `${r()}Posts`, e), s = (e) => f(t, `${r()}Posts`, e, "body", "main"), g = (e) => l(t, `${r()}Posts`, e, "comments");
299
309
  function D() {
300
310
  return f(i()).id;
301
311
  }
@@ -324,7 +334,7 @@ function ve() {
324
334
  let n = (await h(b(i(), T("category", "==", e), T("slug", "==", t), _(1)))).docs[0];
325
335
  return n ? O(n.id) : null;
326
336
  }
327
- async function k(e) {
337
+ async function ne(e) {
328
338
  if (!e.category || !e.createdAt) return {
329
339
  previous: null,
330
340
  next: null
@@ -341,10 +351,10 @@ function ve() {
341
351
  next: a(r)
342
352
  };
343
353
  }
344
- async function ne(e) {
345
- let t = q(e.title) || "post", n = t, r = 1;
354
+ async function re(e) {
355
+ let t = K(e.title) || "post", n = t, r = 1;
346
356
  for (; !(await h(b(i(), T("category", "==", e.category || ""), T("slug", "==", n), _(1)))).empty;) r++, n = `${t}-${r}`;
347
- let o = e.postId || D(), c = ge(e.content, e.attachments);
357
+ let o = e.postId || D(), c = _e(e.content, e.attachments);
348
358
  return await S(a(o), {
349
359
  slug: n,
350
360
  title: e.title,
@@ -362,8 +372,8 @@ function ve() {
362
372
  updatedAt: x()
363
373
  }), await S(s(o), { content: e.content }), n;
364
374
  }
365
- async function re(e, t) {
366
- let n = ge(t.content, t.attachments);
375
+ async function ie(e, t) {
376
+ let n = _e(t.content, t.attachments);
367
377
  await Promise.all([w(a(e), {
368
378
  title: t.title,
369
379
  ...n,
@@ -375,7 +385,7 @@ function ve() {
375
385
  updatedAt: x()
376
386
  }), S(s(e), { content: t.content }, { merge: !0 })]);
377
387
  }
378
- async function A(e) {
388
+ async function k(e) {
379
389
  let [i, o] = await Promise.all([m(a(e)), m(s(e))]);
380
390
  if (!i.exists()) return;
381
391
  let c = i.data(), l = o.exists() ? String(o.data().content ?? "") : "", d = await h(g(e));
@@ -383,42 +393,42 @@ function ve() {
383
393
  let n = E(t);
384
394
  d.docs.slice(e, e + 450).forEach((e) => n.delete(e.ref)), await n.commit();
385
395
  }
386
- let f = M(n), p = /* @__PURE__ */ new Set(), _ = /* @__PURE__ */ new Set();
396
+ let f = j(n), p = /* @__PURE__ */ new Set(), _ = /* @__PURE__ */ new Set();
387
397
  for (let t of c.attachments ?? []) if (t?.path) {
388
398
  p.add(t.path);
389
- let n = J(t.path);
399
+ let n = q(t.path);
390
400
  n && n !== e && _.add(n);
391
401
  }
392
- for (let t of pe(l)) {
393
- let n = me(t);
402
+ for (let t of me(l)) {
403
+ let n = he(t);
394
404
  if (!n) continue;
395
405
  p.add(n);
396
406
  let r = n.replace(/\/images\/([^/]+)$/, "/images/thumbnails/$1").replace(/\.[^.]+$/, ".jpg");
397
407
  r !== n && r.includes("/images/thumbnails/") && p.add(r);
398
408
  let i = n.match(/^(.*\/images\/)([^/]+)\.[^.]+$/);
399
409
  i && !n.includes("/thumbnails/") && p.add(`${i[1]}thumbnails/${i[2]}.jpg`);
400
- let a = J(n);
410
+ let a = q(n);
401
411
  a && a !== e && _.add(a);
402
412
  }
403
- await u(s(e)), await X(P(f, `${r()}/posts/${e}`));
404
- for (let e of _) await X(P(f, `${r()}/posts/${e}`));
405
- await _e(f, p), await u(a(e));
413
+ await u(s(e)), await Y(M(f, `${r()}/posts/${e}`));
414
+ for (let e of _) await Y(M(f, `${r()}/posts/${e}`));
415
+ await ve(f, p), await u(a(e));
406
416
  }
407
417
  return {
408
418
  createPostId: D,
409
419
  getPosts: ee,
410
420
  getPost: O,
411
421
  getPostBySlug: te,
412
- getAdjacentPosts: k,
413
- createPost: ne,
414
- updatePost: re,
415
- deletePost: A
422
+ getAdjacentPosts: ne,
423
+ createPost: re,
424
+ updatePost: ie,
425
+ deletePost: k
416
426
  };
417
427
  }
418
428
  //#endregion
419
429
  //#region src/composables/useMemiBoardComments.ts
420
- function ye(t) {
421
- let n = z(), r = c(), a = () => n.collectionPrefix, o = e(() => typeof t == "string" ? t : t.value), s = e(() => b(l(r, `${a()}Posts`, o.value, "comments"), y("createdAt", "asc"))), u = i(s);
430
+ function be(t) {
431
+ let n = L(), r = c(), a = () => n.collectionPrefix, o = e(() => typeof t == "string" ? t : t.value), s = e(() => b(l(r, `${a()}Posts`, o.value, "comments"), y("createdAt", "asc"))), u = i(s);
422
432
  async function d(e) {
423
433
  let t = E(r), n = a(), i = f(l(r, `${n}Posts`, o.value, "comments"));
424
434
  t.set(i, {
@@ -443,7 +453,7 @@ function ye(t) {
443
453
  }
444
454
  //#endregion
445
455
  //#region src/utils/moderation-prompt.ts
446
- var be = "당신은 게시판 콘텐츠를 심사하는 엄격한 모더레이터입니다.\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은 \"\"", xe = {
456
+ var xe = "당신은 게시판 콘텐츠를 심사하는 엄격한 모더레이터입니다.\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은 \"\"", Se = {
447
457
  abuse: "욕설·비속어·모욕",
448
458
  spam: "스팸·광고",
449
459
  adult: "선정적·성인 내용",
@@ -451,17 +461,17 @@ var be = "당신은 게시판 콘텐츠를 심사하는 엄격한 모더레이
451
461
  other: "부적절한 내용",
452
462
  none: ""
453
463
  };
454
- function Z(e) {
464
+ function X(e) {
455
465
  if (e.via === "local" && e.localHit) {
456
466
  let t = e.localHit.trim();
457
467
  return t.length > 0 && t.length <= 12 ? `「${t}」 같은 표현은 사용할 수 없습니다. 다른 말로 바꿔 주세요.` : "욕설·비속어가 포함되어 게시할 수 없습니다. 표현을 바꿔 주세요.";
458
468
  }
459
469
  let t = (e.reason || "").trim();
460
470
  if (t) return t;
461
- let n = e.category && e.category !== "none" ? xe[e.category] || e.category : "";
471
+ let n = e.category && e.category !== "none" ? Se[e.category] || e.category : "";
462
472
  return n ? `${n}이(가) 포함되어 게시할 수 없습니다. 내용을 수정해 주세요.` : "게시할 수 없는 내용이 포함되어 있습니다. 내용을 수정해 주세요.";
463
473
  }
464
- var Se = [
474
+ var Ce = [
465
475
  "씨발",
466
476
  "시발",
467
477
  "시이발",
@@ -487,10 +497,10 @@ var Se = [
487
497
  "bitch",
488
498
  "asshole"
489
499
  ];
490
- function Ce(e) {
500
+ function we(e) {
491
501
  return new RegExp(e.join("|"), "i");
492
502
  }
493
- var we = /* @__PURE__ */ new Set([
503
+ var Te = /* @__PURE__ */ new Set([
494
504
  "none",
495
505
  "abuse",
496
506
  "spam",
@@ -498,7 +508,7 @@ var we = /* @__PURE__ */ new Set([
498
508
  "violence",
499
509
  "other"
500
510
  ]);
501
- function Te(e) {
511
+ function Ee(e) {
502
512
  let t = e.trim();
503
513
  if (!t) return null;
504
514
  let n;
@@ -516,7 +526,7 @@ function Te(e) {
516
526
  if (typeof n != "object" || !n) return null;
517
527
  let r = n;
518
528
  if (typeof r.flagged != "boolean") return null;
519
- let i = typeof r.category == "string" && we.has(r.category) ? r.category : r.flagged ? "other" : "none", a = typeof r.reason == "string" ? r.reason : "";
529
+ let i = typeof r.category == "string" && Te.has(r.category) ? r.category : r.flagged ? "other" : "none", a = typeof r.reason == "string" ? r.reason : "";
520
530
  return {
521
531
  flagged: r.flagged,
522
532
  category: i,
@@ -525,20 +535,20 @@ function Te(e) {
525
535
  }
526
536
  //#endregion
527
537
  //#region src/composables/useMemiBoardModeration.ts
528
- var Ee = {
538
+ var Z = {
529
539
  flagged: !1,
530
540
  category: "none",
531
541
  reason: "",
532
542
  via: "empty"
533
543
  };
534
544
  function De() {
535
- let e = z().moderation ?? {}, t = o(), { isWriteRestricted: n, restrictedMessage: r, recordContentModerationBlock: i } = K(), a = Ce([...Se, ...e.localBlocklist ?? []]);
545
+ let e = L().moderation ?? {}, t = o(), { isWriteRestricted: n, restrictedMessage: r, recordContentModerationBlock: i } = G(), a = we([...Ce, ...e.localBlocklist ?? []]);
536
546
  function s(e) {
537
547
  let t = e.match(a);
538
548
  return t ? {
539
549
  flagged: !0,
540
550
  category: "abuse",
541
- reason: Z({
551
+ reason: X({
542
552
  via: "local",
543
553
  localHit: t[0] || "",
544
554
  category: "abuse"
@@ -547,21 +557,21 @@ function De() {
547
557
  } : null;
548
558
  }
549
559
  function c() {
550
- let n = e.model ?? "gemini-3.5-flash-lite", r = e.useLimitedUseAppCheckTokens !== !1, i = oe(t, {
551
- backend: new ae(),
560
+ let n = e.model ?? "gemini-3.5-flash-lite", r = e.useLimitedUseAppCheckTokens !== !1, i = ce(t, {
561
+ backend: new se(),
552
562
  useLimitedUseAppCheckTokens: r
553
563
  });
554
- return se(i, {
564
+ return le(i, {
555
565
  model: n,
556
- systemInstruction: be,
566
+ systemInstruction: xe,
557
567
  generationConfig: {
558
568
  temperature: 0,
559
569
  maxOutputTokens: 256,
560
570
  responseMimeType: "application/json",
561
- responseSchema: I.object({ properties: {
562
- flagged: I.boolean(),
563
- category: I.string(),
564
- reason: I.string()
571
+ responseSchema: P.object({ properties: {
572
+ flagged: P.boolean(),
573
+ category: P.string(),
574
+ reason: P.string()
565
575
  } })
566
576
  }
567
577
  });
@@ -582,7 +592,7 @@ function De() {
582
592
  };
583
593
  }
584
594
  function u(e, t = "") {
585
- let n = Z({
595
+ let n = X({
586
596
  reason: e.reason,
587
597
  category: e.category,
588
598
  via: e.via
@@ -610,22 +620,22 @@ function De() {
610
620
  };
611
621
  let i = t.trim();
612
622
  if (!i) return {
613
- ...Ee,
623
+ ...Z,
614
624
  via: "empty"
615
625
  };
616
626
  let a = s(i);
617
627
  if (a) return d(a);
618
628
  if (e.enabled === !1) return {
619
- ...Ee,
629
+ ...Z,
620
630
  via: "disabled"
621
631
  };
622
632
  try {
623
- let e = Te((await c().generateContent(`심사할 텍스트:\n"""${i.slice(0, 4e3)}"""`)).response.text());
633
+ let e = Ee((await c().generateContent(`심사할 텍스트:\n"""${i.slice(0, 4e3)}"""`)).response.text());
624
634
  if (!e) return l();
625
635
  let t = {
626
636
  flagged: e.flagged,
627
637
  category: e.category,
628
- reason: Z({
638
+ reason: X({
629
639
  reason: e.reason,
630
640
  category: e.category,
631
641
  via: "ai"
@@ -645,14 +655,14 @@ function De() {
645
655
  via: "restricted"
646
656
  };
647
657
  if (e.enabled === !1 || !e.moderateImages) return {
648
- ...Ee,
658
+ ...Z,
649
659
  via: "disabled"
650
660
  };
651
661
  try {
652
662
  let e = await new Promise((e, n) => {
653
663
  let r = new FileReader();
654
664
  r.onload = () => e(r.result.split(",")[1] ?? ""), r.onerror = n, r.readAsDataURL(t);
655
- }), n = Te((await c().generateContent([{ inlineData: {
665
+ }), n = Ee((await c().generateContent([{ inlineData: {
656
666
  mimeType: t.type || "image/jpeg",
657
667
  data: e
658
668
  } }, "이 이미지를 심사해 주세요."])).response.text());
@@ -660,7 +670,7 @@ function De() {
660
670
  let r = {
661
671
  flagged: n.flagged,
662
672
  category: n.category,
663
- reason: Z({
673
+ reason: X({
664
674
  reason: n.reason,
665
675
  category: n.category,
666
676
  via: "ai"
@@ -701,9 +711,9 @@ async function Oe(e, { maxWidth: t = 400, quality: n = .8 } = {}) {
701
711
  //#region src/composables/useMemiBoardStorage.ts
702
712
  var ke = 5242880;
703
713
  function Ae() {
704
- let e = z(), t = o(), n = () => e.collectionPrefix;
714
+ let e = L(), t = o(), n = () => e.collectionPrefix;
705
715
  function r(e, r, i) {
706
- let a = M(t), o = `${n()}/posts/${r}/attachments/${Date.now()}-${fe(e.name)}`, s = P(a, o), c = F(s, e, {
716
+ let a = j(t), o = `${n()}/posts/${r}/attachments/${Date.now()}-${pe(e.name)}`, s = M(a, o), c = oe(s, e, {
707
717
  contentType: e.type || "application/octet-stream",
708
718
  contentDisposition: `attachment; filename*=UTF-8''${encodeURIComponent(e.name)}`
709
719
  });
@@ -712,7 +722,7 @@ function Ae() {
712
722
  c.on("state_changed", (e) => {
713
723
  i && e.totalBytes > 0 && i(e.bytesTransferred / e.totalBytes);
714
724
  }, n, async () => {
715
- let n = await j(s);
725
+ let n = await A(s);
716
726
  t({
717
727
  name: e.name,
718
728
  url: n,
@@ -726,28 +736,28 @@ function Ae() {
726
736
  };
727
737
  }
728
738
  async function i(e) {
729
- let n = M(t);
730
- await A(P(n, e.path)).catch(() => {});
739
+ let n = j(t);
740
+ await k(M(n, e.path)).catch(() => {});
731
741
  }
732
742
  async function a(e, r) {
733
743
  if (!e.type.startsWith("image/")) throw Error("이미지 파일만 업로드할 수 있습니다.");
734
744
  if (e.size > 5242880) throw Error(`이미지는 ${ke / 1024 / 1024}MB 이하여야 합니다.`);
735
- let i = M(t), a = (e.name.split(".").pop() || e.type.split("/")[1] || "png").replace(/[^a-zA-Z0-9]/g, "").slice(0, 8) || "png", o = fe(e.name.replace(/\.[^.]+$/, "") || "image", 40), s = `${Date.now()}-${o || "image"}`, c = r || `new-${Date.now()}`, l = `${n()}/posts/${c}/images/${s}.${a}`, u = P(i, l);
736
- await ie(u, e, { contentType: e.type || "image/png" });
737
- let d = await j(u), f = await Oe(e, {
745
+ let i = j(t), a = (e.name.split(".").pop() || e.type.split("/")[1] || "png").replace(/[^a-zA-Z0-9]/g, "").slice(0, 8) || "png", o = pe(e.name.replace(/\.[^.]+$/, "") || "image", 40), s = `${Date.now()}-${o || "image"}`, c = r || `new-${Date.now()}`, l = `${n()}/posts/${c}/images/${s}.${a}`, u = M(i, l);
746
+ await N(u, e, { contentType: e.type || "image/png" });
747
+ let d = await A(u), f = await Oe(e, {
738
748
  maxWidth: 400,
739
749
  quality: .8
740
- }), p = `${n()}/posts/${c}/images/thumbnails/${s}.jpg`, m = P(i, p);
741
- return await ie(m, f, { contentType: "image/jpeg" }), {
750
+ }), p = `${n()}/posts/${c}/images/thumbnails/${s}.jpg`, m = M(i, p);
751
+ return await N(m, f, { contentType: "image/jpeg" }), {
742
752
  originalUrl: d,
743
753
  originalPath: l,
744
- thumbnailUrl: await j(m),
754
+ thumbnailUrl: await A(m),
745
755
  thumbnailPath: p
746
756
  };
747
757
  }
748
758
  async function s(e) {
749
- let n = M(t);
750
- await Promise.all([A(P(n, e.originalPath)).catch(() => {}), A(P(n, e.thumbnailPath)).catch(() => {})]);
759
+ let n = j(t);
760
+ await Promise.all([k(M(n, e.originalPath)).catch(() => {}), k(M(n, e.thumbnailPath)).catch(() => {})]);
751
761
  }
752
762
  return {
753
763
  uploadAttachment: r,
@@ -763,27 +773,31 @@ var je = [
763
773
  id: "free",
764
774
  label: "자유",
765
775
  listView: "default",
776
+ writeRole: "user",
766
777
  order: 0
767
778
  },
768
779
  {
769
780
  id: "notice",
770
781
  label: "공지",
771
782
  listView: "default",
783
+ writeRole: "admin",
772
784
  order: 1
773
785
  },
774
786
  {
775
787
  id: "question",
776
788
  label: "질문",
777
789
  listView: "default",
790
+ writeRole: "user",
778
791
  order: 2
779
792
  }
780
793
  ];
781
794
  function Me() {
782
- let t = z(), n = c(), { isSignedIn: r } = K(), a = e(() => f(n, `${t.collectionPrefix}Settings`, "config")), o = e(() => l(n, `${t.collectionPrefix}Settings`, "config", "categories")), s = e(() => b(o.value, y("order", "asc"))), { data: d, pending: p } = i(s), m = e(() => d.value.map((e, t) => ({
795
+ let t = L(), n = c(), { isSignedIn: r } = G(), a = e(() => f(n, `${t.collectionPrefix}Settings`, "config")), o = e(() => l(n, `${t.collectionPrefix}Settings`, "config", "categories")), s = e(() => b(o.value, y("order", "asc"))), { data: d, pending: p } = i(s, { ssrKey: `${t.collectionPrefix}Settings/config/categories` }), m = e(() => d.value.map((e, t) => ({
783
796
  ...e,
784
797
  id: e.id,
785
798
  order: typeof e.order == "number" ? e.order : t,
786
- listView: e.listView ?? "default"
799
+ listView: e.listView ?? "default",
800
+ writeRole: e.writeRole ?? "user"
787
801
  })));
788
802
  function h(e) {
789
803
  if (e) return m.value.find((t) => t.id === e)?.label ?? e;
@@ -795,6 +809,7 @@ function Me() {
795
809
  await S(a.value, { updatedAt: x() }, { merge: !0 }), await S(f(o.value, n), {
796
810
  label: i,
797
811
  listView: e.listView ?? "default",
812
+ writeRole: e.writeRole ?? "user",
798
813
  order: t,
799
814
  updatedAt: x()
800
815
  }, { merge: !0 });
@@ -808,6 +823,7 @@ function Me() {
808
823
  t.set(f(o.value, r), {
809
824
  label: i,
810
825
  listView: e.listView ?? "default",
826
+ writeRole: e.writeRole ?? "user",
811
827
  order: n,
812
828
  updatedAt: x()
813
829
  }, { merge: !0 });
@@ -826,12 +842,13 @@ function Me() {
826
842
  if (!r.value) throw Error("로그인이 필요합니다.");
827
843
  let n = m.value.find((e) => e.label === t);
828
844
  if (n) return n.id;
829
- let i = q(t) || `cat-${Date.now()}`, a = i, o = 2, s = new Set(m.value.map((e) => e.id));
845
+ let i = K(t) || `cat-${Date.now()}`, a = i, o = 2, s = new Set(m.value.map((e) => e.id));
830
846
  for (; s.has(a);) a = `${i}-${o++}`;
831
847
  return await g({
832
848
  id: a,
833
849
  label: t,
834
850
  listView: "default",
851
+ writeRole: "user",
835
852
  order: m.value.length
836
853
  }), a;
837
854
  }
@@ -848,8 +865,52 @@ function Me() {
848
865
  };
849
866
  }
850
867
  //#endregion
851
- //#region src/data/versionHistory.ts
868
+ //#region src/composables/useMemiBoardUsers.ts
852
869
  var Ne = [
870
+ {
871
+ value: "admin",
872
+ label: "관리자",
873
+ description: "사용자 역할, 게시판 설정과 모든 콘텐츠를 관리합니다."
874
+ },
875
+ {
876
+ value: "staff",
877
+ label: "스태프",
878
+ description: "모든 게시글과 댓글을 관리하지만 사용자 역할과 게시판 설정은 바꿀 수 없습니다."
879
+ },
880
+ {
881
+ value: "user",
882
+ label: "사용자",
883
+ description: "글과 댓글을 작성하고 본인이 작성한 콘텐츠만 수정·삭제할 수 있습니다."
884
+ }
885
+ ];
886
+ function Pe() {
887
+ let e = L(), t = c(), n = `${e.collectionPrefix}Users`, r = i(l(t, n), { ssrKey: `${n}-management` });
888
+ async function a(e, r) {
889
+ if (!Ne.some((e) => e.value === r)) throw Error("지원하지 않는 역할입니다.");
890
+ await w(f(t, n, e), {
891
+ role: r,
892
+ updatedAt: x()
893
+ });
894
+ }
895
+ return {
896
+ users: r,
897
+ usersPending: r.pending,
898
+ updateUserRole: a
899
+ };
900
+ }
901
+ //#endregion
902
+ //#region src/data/versionHistory.ts
903
+ var Fe = [
904
+ {
905
+ version: "0.7.0",
906
+ date: "2026-08-04",
907
+ highlights: ["카테고리마다 글쓰기 권한을 일반 이상·스태프 이상·관리자만으로 설정할 수 있어요", "게시판 설정 화면의 SSR hydration 경고를 해결했어요"]
908
+ },
909
+ {
910
+ version: "0.6.0",
911
+ date: "2026-08-04",
912
+ highlights: ["관리자가 게시판 사용자를 확인하고 관리자·스태프·사용자 역할을 설정할 수 있어요", "스태프는 게시판 설정이나 사용자 역할은 바꾸지 않고 게시글과 댓글을 관리할 수 있어요"]
913
+ },
853
914
  {
854
915
  version: "0.5.0",
855
916
  date: "2026-08-04",
@@ -896,7 +957,7 @@ var Ne = [
896
957
  ];
897
958
  //#endregion
898
959
  //#region src/utils/formatDate.ts
899
- function Pe(e) {
960
+ function Ie(e) {
900
961
  return e?.toDate ? e.toDate().toLocaleDateString("ko-KR") : "";
901
962
  }
902
963
  //#endregion
@@ -908,7 +969,7 @@ function $(e) {
908
969
  let t = e;
909
970
  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;
910
971
  }
911
- function Fe(e) {
972
+ function Le(e) {
912
973
  if (!e?.trim()) return "";
913
974
  let t = e.replace(/\r\n/g, "\n").split("\n"), n = [], r = 0, i = !1, a = [], o = null, s = [];
914
975
  function c() {
@@ -973,6 +1034,6 @@ function Fe(e) {
973
1034
  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");
974
1035
  }
975
1036
  //#endregion
976
- export { B as DEFAULT_BLOCK_BAN_DECAY_MS, le as DEFAULT_BLOCK_BAN_THRESHOLD, je as DEFAULT_CATEGORIES, ke as EDITOR_IMAGE_MAX_BYTES, Oe as compressImage, ce as configureMemiBoard, V as effectiveModerationBlockCount, pe as extractEditorImageUrls, Pe as formatDate, W as formatRestrictedUntilLabel, U as isModerationWriteRestricted, H as moderationWriteRestrictedUntilMs, J as postNamespaceFromStoragePath, Fe as renderMarkdownToHtml, q as slugify, me as storagePathFromDownloadUrl, K as useMemiBoardAuth, ye as useMemiBoardComments, z as useMemiBoardConfig, De as useMemiBoardModeration, ve as useMemiBoardPosts, Me as useMemiBoardSettings, Ae as useMemiBoardStorage, Ne as versionHistory };
1037
+ export { Ne as BOARD_USER_ROLES, z as DEFAULT_BLOCK_BAN_DECAY_MS, R as DEFAULT_BLOCK_BAN_THRESHOLD, je as DEFAULT_CATEGORIES, ke as EDITOR_IMAGE_MAX_BYTES, Oe as compressImage, ue as configureMemiBoard, B as effectiveModerationBlockCount, me as extractEditorImageUrls, Ie as formatDate, U as formatRestrictedUntilLabel, H as isModerationWriteRestricted, V as moderationWriteRestrictedUntilMs, q as postNamespaceFromStoragePath, Le as renderMarkdownToHtml, K as slugify, he as storagePathFromDownloadUrl, G as useMemiBoardAuth, be as useMemiBoardComments, L as useMemiBoardConfig, De as useMemiBoardModeration, ye as useMemiBoardPosts, Me as useMemiBoardSettings, Ae as useMemiBoardStorage, Pe as useMemiBoardUsers, Fe as versionHistory };
977
1038
 
978
1039
  //# sourceMappingURL=index.js.map