memi-board 0.5.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -8
- package/dist/composables/useMemiBoardAuth.d.ts +2 -0
- package/dist/composables/useMemiBoardComments.d.ts +81 -4
- package/dist/composables/useMemiBoardUsers.d.ts +11 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +501 -265
- package/dist/index.js.map +1 -1
- package/dist/module.mjs +4 -0
- package/dist/module.mjs.map +1 -1
- package/dist/runtime/components/CommentForm.vue +21 -6
- package/dist/runtime/components/CommentItem.vue +73 -0
- package/dist/runtime/components/CommentList.vue +83 -39
- package/dist/runtime/components/CommentSkeleton.vue +12 -0
- package/dist/runtime/components/CommentThread.vue +121 -0
- package/dist/runtime/components/Detail.vue +3 -6
- package/dist/runtime/components/Settings.vue +24 -4
- package/dist/runtime/components/Users.vue +64 -0
- package/dist/types.d.ts +23 -2
- package/docs/firestore.rules.example +88 -14
- package/docs/storage.rules.example +6 -6
- package/package.json +3 -1
package/dist/index.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
import { computed as e,
|
|
2
|
-
import { useCollection as
|
|
3
|
-
import { collection as
|
|
4
|
-
import { GoogleAuthProvider as
|
|
5
|
-
import { deleteObject as
|
|
6
|
-
import { GoogleAIBackend as
|
|
1
|
+
import { computed as e, onScopeDispose as t, reactive as n, ref as r, watch as i } from "vue";
|
|
2
|
+
import { useCollection as a, useCurrentUser 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, documentId as m, endBefore as h, getDoc as g, getDocs as _, increment as v, limit as y, limitToLast as b, onSnapshot as x, orderBy as S, query as C, serverTimestamp as w, setDoc as T, startAfter as E, updateDoc as D, where as O, writeBatch as k } from "firebase/firestore";
|
|
4
|
+
import { GoogleAuthProvider as A, OAuthProvider as j, createUserWithEmailAndPassword as M, signInWithEmailAndPassword as N, signInWithPopup as P, signOut as ee, updateProfile as te } from "firebase/auth";
|
|
5
|
+
import { deleteObject as F, getDownloadURL as I, getStorage as L, listAll as ne, ref as R, uploadBytes as z, uploadBytesResumable as re } from "firebase/storage";
|
|
6
|
+
import { GoogleAIBackend as ie, Schema as B, getAI as ae, getGenerativeModel as oe } from "firebase/ai";
|
|
7
7
|
//#region src/config.ts
|
|
8
|
-
var
|
|
9
|
-
function
|
|
8
|
+
var V = "__MEMI_BOARD_CONFIG__";
|
|
9
|
+
function H() {
|
|
10
10
|
let e = globalThis;
|
|
11
|
-
return e[
|
|
11
|
+
return e[V] || (e[V] = n({
|
|
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[
|
|
23
|
+
})), e[V];
|
|
24
24
|
}
|
|
25
|
-
function
|
|
26
|
-
let t =
|
|
25
|
+
function se(e) {
|
|
26
|
+
let t = H();
|
|
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
|
|
30
|
-
return
|
|
29
|
+
function U() {
|
|
30
|
+
return H();
|
|
31
31
|
}
|
|
32
32
|
//#endregion
|
|
33
33
|
//#region src/utils/moderation-strike.ts
|
|
34
|
-
var
|
|
35
|
-
function
|
|
34
|
+
var ce = 3, W = 864e5;
|
|
35
|
+
function G(e, t, n = Date.now(), r = W) {
|
|
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
|
|
43
|
+
function K(e, t, n = Date.now(), r = 3, i = W) {
|
|
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
|
|
52
|
-
return
|
|
51
|
+
function q(e, t, n = Date.now(), r = 3, i = W) {
|
|
52
|
+
return K(e, t, n, r, i) != null;
|
|
53
53
|
}
|
|
54
|
-
function
|
|
54
|
+
function J(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
|
|
61
|
+
function le(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,168 +70,178 @@ function ue(e) {
|
|
|
70
70
|
}
|
|
71
71
|
//#endregion
|
|
72
72
|
//#region src/composables/useMemiBoardAuth.ts
|
|
73
|
-
var
|
|
74
|
-
function
|
|
73
|
+
var Y = "__MEMI_BOARD_AUTH_STATE__";
|
|
74
|
+
function ue() {
|
|
75
75
|
let e = globalThis;
|
|
76
|
-
return e[
|
|
77
|
-
role:
|
|
78
|
-
rolePending:
|
|
79
|
-
rawBlockCount:
|
|
80
|
-
blockAtMs:
|
|
76
|
+
return e[Y] || (e[Y] = {
|
|
77
|
+
role: r("user"),
|
|
78
|
+
rolePending: r(!0),
|
|
79
|
+
rawBlockCount: r(0),
|
|
80
|
+
blockAtMs: r(null),
|
|
81
81
|
watchStarted: !1
|
|
82
|
-
}), e[
|
|
82
|
+
}), e[Y];
|
|
83
83
|
}
|
|
84
|
-
function
|
|
85
|
-
let t =
|
|
86
|
-
function
|
|
84
|
+
function X() {
|
|
85
|
+
let t = U(), n = ue(), r = l(), a = c(), s = o(), { role: u, rolePending: d, rawBlockCount: f, blockAtMs: m } = n;
|
|
86
|
+
function h() {
|
|
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
|
-
function
|
|
90
|
+
function _() {
|
|
91
91
|
let e = t.moderation?.blockBanDecayMs;
|
|
92
|
-
return typeof e == "number" && e > 0 ? e :
|
|
92
|
+
return typeof e == "number" && e > 0 ? e : W;
|
|
93
93
|
}
|
|
94
94
|
function y(e) {
|
|
95
|
-
return
|
|
95
|
+
return p(r, `${t.collectionPrefix}Users`, e);
|
|
96
96
|
}
|
|
97
97
|
function b(e) {
|
|
98
98
|
if (!e) {
|
|
99
|
-
|
|
99
|
+
f.value = 0, m.value = null;
|
|
100
100
|
return;
|
|
101
101
|
}
|
|
102
|
-
|
|
102
|
+
f.value = Math.max(0, Math.floor(Number(e.moderationBlockCount) || 0)), m.value = le(e.moderationBlockAt);
|
|
103
103
|
}
|
|
104
|
-
async function
|
|
105
|
-
let t = y(e.uid), n = await
|
|
106
|
-
if (!n.exists()) return await
|
|
104
|
+
async function x(e) {
|
|
105
|
+
let t = y(e.uid), n = await g(t);
|
|
106
|
+
if (!n.exists()) return await T(t, {
|
|
107
107
|
role: "user",
|
|
108
108
|
displayName: e.displayName,
|
|
109
|
-
|
|
109
|
+
email: e.email,
|
|
110
|
+
photoURL: e.photoURL,
|
|
111
|
+
updatedAt: w()
|
|
110
112
|
}), b({ moderationBlockCount: 0 }), "user";
|
|
111
113
|
let r = n.data();
|
|
112
|
-
return b(r), await
|
|
114
|
+
return b(r), await D(t, {
|
|
113
115
|
displayName: e.displayName,
|
|
114
|
-
|
|
115
|
-
|
|
116
|
+
email: e.email,
|
|
117
|
+
photoURL: e.photoURL,
|
|
118
|
+
updatedAt: w()
|
|
119
|
+
}), [
|
|
120
|
+
"admin",
|
|
121
|
+
"staff",
|
|
122
|
+
"user"
|
|
123
|
+
].includes(String(r.role)) ? r.role : "user";
|
|
116
124
|
}
|
|
117
|
-
async function
|
|
118
|
-
let e =
|
|
125
|
+
async function S() {
|
|
126
|
+
let e = s.value?.uid;
|
|
119
127
|
if (!e) {
|
|
120
128
|
b(void 0);
|
|
121
129
|
return;
|
|
122
130
|
}
|
|
123
|
-
let t = await
|
|
131
|
+
let t = await g(y(e));
|
|
124
132
|
b(t.exists() ? t.data() : void 0);
|
|
125
133
|
}
|
|
126
|
-
n.watchStarted || (n.watchStarted = !0,
|
|
134
|
+
n.watchStarted || (n.watchStarted = !0, i(s, async (e) => {
|
|
127
135
|
if (d.value = !0, !e) {
|
|
128
136
|
u.value = "user", b(void 0), d.value = !1;
|
|
129
137
|
return;
|
|
130
138
|
}
|
|
131
139
|
try {
|
|
132
|
-
u.value = await
|
|
140
|
+
u.value = await x(e);
|
|
133
141
|
} catch {
|
|
134
142
|
u.value = "user";
|
|
135
143
|
} finally {
|
|
136
144
|
d.value = !1;
|
|
137
145
|
}
|
|
138
146
|
}, { immediate: !0 }));
|
|
139
|
-
let
|
|
140
|
-
let e =
|
|
141
|
-
return e <= 0 ? !1 :
|
|
142
|
-
}),
|
|
143
|
-
let e =
|
|
147
|
+
let C = e(() => !!s.value), E = e(() => u.value === "admin"), O = e(() => u.value === "staff"), k = e(() => E.value || O.value), F = e(() => G(f.value, m.value, Date.now(), _())), I = e(() => {
|
|
148
|
+
let e = h();
|
|
149
|
+
return e <= 0 ? !1 : q(f.value, m.value, Date.now(), e, _());
|
|
150
|
+
}), L = e(() => {
|
|
151
|
+
let e = h();
|
|
144
152
|
if (e <= 0) return null;
|
|
145
|
-
let t =
|
|
146
|
-
return t == null ? null :
|
|
147
|
-
}),
|
|
148
|
-
if (!
|
|
149
|
-
let e =
|
|
153
|
+
let t = K(f.value, m.value, Date.now(), e, _());
|
|
154
|
+
return t == null ? null : J(t);
|
|
155
|
+
}), ne = e(() => {
|
|
156
|
+
if (!I.value) return null;
|
|
157
|
+
let e = L.value;
|
|
150
158
|
return e ? `콘텐츠 경고가 누적되어 글·댓글 작성이 잠시 제한됐어요. (${e})` : "콘텐츠 경고가 누적되어 글·댓글 작성이 잠시 제한됐어요.";
|
|
151
159
|
});
|
|
152
|
-
async function
|
|
153
|
-
let e =
|
|
160
|
+
async function R() {
|
|
161
|
+
let e = s.value?.uid, t = h();
|
|
154
162
|
if (!e || t <= 0) return {
|
|
155
163
|
effectiveCount: 0,
|
|
156
164
|
restricted: !1,
|
|
157
165
|
messageSuffix: ""
|
|
158
166
|
};
|
|
159
|
-
let n = y(e), r = await
|
|
160
|
-
r.exists() ? await
|
|
161
|
-
moderationBlockCount:
|
|
162
|
-
moderationBlockAt:
|
|
163
|
-
updatedAt:
|
|
164
|
-
}) : await
|
|
167
|
+
let n = y(e), r = await g(n);
|
|
168
|
+
r.exists() ? await D(n, {
|
|
169
|
+
moderationBlockCount: v(1),
|
|
170
|
+
moderationBlockAt: w(),
|
|
171
|
+
updatedAt: w()
|
|
172
|
+
}) : await T(n, {
|
|
165
173
|
role: "user",
|
|
166
|
-
displayName:
|
|
174
|
+
displayName: s.value?.displayName ?? null,
|
|
167
175
|
moderationBlockCount: 1,
|
|
168
|
-
moderationBlockAt:
|
|
169
|
-
updatedAt:
|
|
176
|
+
moderationBlockAt: w(),
|
|
177
|
+
updatedAt: w()
|
|
170
178
|
});
|
|
171
179
|
let i = (r.exists() ? Math.max(0, Math.floor(Number(r.data().moderationBlockCount) || 0)) : 0) + 1, a = Date.now();
|
|
172
|
-
|
|
173
|
-
let o =
|
|
174
|
-
if (
|
|
175
|
-
let e =
|
|
176
|
-
|
|
180
|
+
f.value = i, m.value = a;
|
|
181
|
+
let o = G(i, a, a, _()), c = q(i, a, a, t, _()), l = ` (경고 ${o}/${t})`;
|
|
182
|
+
if (c) {
|
|
183
|
+
let e = K(i, a, a, t, _()), n = e == null ? null : J(e, a);
|
|
184
|
+
l = n ? ` 경고 ${o}회가 되어 글·댓글 작성이 제한됩니다 (${n}).` : ` 경고 ${o}회가 되어 글·댓글 작성이 제한됩니다.`;
|
|
177
185
|
}
|
|
178
186
|
return {
|
|
179
187
|
effectiveCount: o,
|
|
180
|
-
restricted:
|
|
181
|
-
messageSuffix:
|
|
188
|
+
restricted: c,
|
|
189
|
+
messageSuffix: l
|
|
182
190
|
};
|
|
183
191
|
}
|
|
184
|
-
function
|
|
185
|
-
if (!
|
|
186
|
-
return
|
|
192
|
+
function z() {
|
|
193
|
+
if (!a) throw Error("[memi-board] Firebase Auth가 초기화되지 않았습니다.");
|
|
194
|
+
return a;
|
|
187
195
|
}
|
|
188
|
-
async function
|
|
189
|
-
return
|
|
196
|
+
async function re() {
|
|
197
|
+
return P(z(), new A());
|
|
190
198
|
}
|
|
191
|
-
async function
|
|
192
|
-
let e = new
|
|
193
|
-
return e.addScope("email"), e.addScope("name"),
|
|
199
|
+
async function ie() {
|
|
200
|
+
let e = new j("apple.com");
|
|
201
|
+
return e.addScope("email"), e.addScope("name"), P(z(), e);
|
|
194
202
|
}
|
|
195
|
-
async function
|
|
196
|
-
return
|
|
203
|
+
async function B(e, t) {
|
|
204
|
+
return N(z(), e, t);
|
|
197
205
|
}
|
|
198
|
-
async function
|
|
199
|
-
let r = await
|
|
200
|
-
return n && await
|
|
206
|
+
async function ae(e, t, n) {
|
|
207
|
+
let r = await M(z(), e, t);
|
|
208
|
+
return n && await te(r.user, { displayName: n }), r;
|
|
201
209
|
}
|
|
202
|
-
async function
|
|
203
|
-
await
|
|
210
|
+
async function oe() {
|
|
211
|
+
await ee(z());
|
|
204
212
|
}
|
|
205
|
-
function
|
|
206
|
-
return
|
|
213
|
+
function V(e) {
|
|
214
|
+
return C.value && (s.value?.uid === e.authorUid || k.value);
|
|
207
215
|
}
|
|
208
|
-
function
|
|
209
|
-
return
|
|
216
|
+
function H(e) {
|
|
217
|
+
return C.value && (s.value?.uid === e.authorUid || k.value);
|
|
210
218
|
}
|
|
211
219
|
return {
|
|
212
|
-
user:
|
|
213
|
-
isSignedIn:
|
|
214
|
-
isAdmin:
|
|
220
|
+
user: s,
|
|
221
|
+
isSignedIn: C,
|
|
222
|
+
isAdmin: E,
|
|
223
|
+
isStaff: O,
|
|
224
|
+
canManageContent: k,
|
|
215
225
|
rolePending: d,
|
|
216
|
-
moderationBlockCount:
|
|
217
|
-
isWriteRestricted:
|
|
218
|
-
restrictedUntilLabel:
|
|
219
|
-
restrictedMessage:
|
|
220
|
-
signInWithGoogle:
|
|
221
|
-
signInWithApple:
|
|
222
|
-
signInWithEmail:
|
|
223
|
-
signUpWithEmail:
|
|
224
|
-
signOut:
|
|
225
|
-
canEdit:
|
|
226
|
-
canDelete:
|
|
227
|
-
canDeleteComment:
|
|
228
|
-
recordContentModerationBlock:
|
|
229
|
-
refreshBoardUser:
|
|
226
|
+
moderationBlockCount: F,
|
|
227
|
+
isWriteRestricted: I,
|
|
228
|
+
restrictedUntilLabel: L,
|
|
229
|
+
restrictedMessage: ne,
|
|
230
|
+
signInWithGoogle: re,
|
|
231
|
+
signInWithApple: ie,
|
|
232
|
+
signInWithEmail: B,
|
|
233
|
+
signUpWithEmail: ae,
|
|
234
|
+
signOut: oe,
|
|
235
|
+
canEdit: V,
|
|
236
|
+
canDelete: V,
|
|
237
|
+
canDeleteComment: H,
|
|
238
|
+
recordContentModerationBlock: R,
|
|
239
|
+
refreshBoardUser: S
|
|
230
240
|
};
|
|
231
241
|
}
|
|
232
242
|
//#endregion
|
|
233
243
|
//#region src/utils/slugify.ts
|
|
234
|
-
function
|
|
244
|
+
function de(e) {
|
|
235
245
|
return e.trim().replace(/\s+/g, "-").replace(/[^a-zA-Z0-9가-힣ㄱ-ㅎㅏ-ㅣ-]/g, "").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
|
|
236
246
|
}
|
|
237
247
|
function fe(e, t = 60) {
|
|
@@ -256,25 +266,25 @@ function me(e) {
|
|
|
256
266
|
return null;
|
|
257
267
|
}
|
|
258
268
|
}
|
|
259
|
-
function
|
|
269
|
+
function he(e) {
|
|
260
270
|
return e.match(/(?:^|\/)posts\/([^/]+)\//)?.[1] ?? null;
|
|
261
271
|
}
|
|
262
272
|
//#endregion
|
|
263
273
|
//#region src/utils/postPreview.ts
|
|
264
|
-
function
|
|
274
|
+
function ge(e) {
|
|
265
275
|
return e.replace(/&/g, "&").replace(/"/g, "\"").replace(/'/g, "'");
|
|
266
276
|
}
|
|
267
|
-
function
|
|
277
|
+
function _e(e) {
|
|
268
278
|
try {
|
|
269
|
-
let t = new URL(
|
|
279
|
+
let t = new URL(ge(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
|
|
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 ?
|
|
286
|
+
function ve(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 ? ge(r[1]) : a, s = o ? _e(o) : void 0, c = n ? ge(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(/ /g, " ").replace(/&/g, "&").replace(/\s+/g, " ").trim().slice(0, 160),
|
|
280
290
|
...c ? { previewImage: c } : {},
|
|
@@ -283,26 +293,26 @@ function ge(e, t = []) {
|
|
|
283
293
|
}
|
|
284
294
|
//#endregion
|
|
285
295
|
//#region src/composables/useMemiBoardPosts.ts
|
|
286
|
-
async function
|
|
287
|
-
let t = await
|
|
296
|
+
async function ye(e) {
|
|
297
|
+
let t = await ne(e).catch(() => ({
|
|
288
298
|
items: [],
|
|
289
299
|
prefixes: []
|
|
290
300
|
}));
|
|
291
|
-
await Promise.all([...t.items.map((e) =>
|
|
301
|
+
await Promise.all([...t.items.map((e) => F(e)), ...t.prefixes.map((e) => ye(e))]);
|
|
292
302
|
}
|
|
293
|
-
async function
|
|
303
|
+
async function be(e, t) {
|
|
294
304
|
let n = [...new Set([...t].filter(Boolean))];
|
|
295
|
-
await Promise.all(n.map((t) =>
|
|
305
|
+
await Promise.all(n.map((t) => F(R(e, t)).catch(() => {})));
|
|
296
306
|
}
|
|
297
|
-
function
|
|
298
|
-
let e =
|
|
299
|
-
function
|
|
300
|
-
return
|
|
307
|
+
function xe() {
|
|
308
|
+
let e = U(), 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");
|
|
309
|
+
function m() {
|
|
310
|
+
return p(i()).id;
|
|
301
311
|
}
|
|
302
|
-
async function
|
|
312
|
+
async function v(e = {}) {
|
|
303
313
|
let t = e.pageSize ?? 20, n = [];
|
|
304
|
-
e.category && n.push(
|
|
305
|
-
let r = await
|
|
314
|
+
e.category && n.push(O("category", "==", e.category)), n.push(S("createdAt", "desc")), e.cursor && n.push(E(e.cursor)), n.push(y(t + 1));
|
|
315
|
+
let r = await _(C(i(), ...n)), a = r.docs.slice(0, t);
|
|
306
316
|
return {
|
|
307
317
|
posts: a.map((e) => ({
|
|
308
318
|
id: e.id,
|
|
@@ -312,24 +322,24 @@ function ve() {
|
|
|
312
322
|
hasMore: r.docs.length > t
|
|
313
323
|
};
|
|
314
324
|
}
|
|
315
|
-
async function
|
|
316
|
-
let [t, n] = await Promise.all([
|
|
325
|
+
async function x(e) {
|
|
326
|
+
let [t, n] = await Promise.all([g(a(e)), g(o(e))]);
|
|
317
327
|
return t.exists() ? {
|
|
318
328
|
id: t.id,
|
|
319
329
|
...t.data(),
|
|
320
330
|
content: n.exists() ? n.data().content ?? "" : ""
|
|
321
331
|
} : null;
|
|
322
332
|
}
|
|
323
|
-
async function
|
|
324
|
-
let n = (await
|
|
325
|
-
return n ?
|
|
333
|
+
async function A(e, t) {
|
|
334
|
+
let n = (await _(C(i(), O("category", "==", e), O("slug", "==", t), y(1)))).docs[0];
|
|
335
|
+
return n ? x(n.id) : null;
|
|
326
336
|
}
|
|
327
|
-
async function
|
|
337
|
+
async function j(e) {
|
|
328
338
|
if (!e.category || !e.createdAt) return {
|
|
329
339
|
previous: null,
|
|
330
340
|
next: null
|
|
331
341
|
};
|
|
332
|
-
let t = [
|
|
342
|
+
let t = [O("category", "==", e.category), S("createdAt", "desc")], [n, r] = await Promise.all([_(C(i(), ...t, E(e.createdAt), y(1))), _(C(i(), ...t, h(e.createdAt), b(1)))]), a = (e) => {
|
|
333
343
|
let t = e.docs[0];
|
|
334
344
|
return t ? {
|
|
335
345
|
id: t.id,
|
|
@@ -341,11 +351,11 @@ function ve() {
|
|
|
341
351
|
next: a(r)
|
|
342
352
|
};
|
|
343
353
|
}
|
|
344
|
-
async function
|
|
345
|
-
let t =
|
|
346
|
-
for (; !(await
|
|
347
|
-
let
|
|
348
|
-
return await
|
|
354
|
+
async function M(e) {
|
|
355
|
+
let t = de(e.title) || "post", n = t, r = 1;
|
|
356
|
+
for (; !(await _(C(i(), O("category", "==", e.category || ""), O("slug", "==", n), y(1)))).empty;) r++, n = `${t}-${r}`;
|
|
357
|
+
let s = e.postId || m(), c = ve(e.content, e.attachments);
|
|
358
|
+
return await T(a(s), {
|
|
349
359
|
slug: n,
|
|
350
360
|
title: e.title,
|
|
351
361
|
...c,
|
|
@@ -358,92 +368,249 @@ function ve() {
|
|
|
358
368
|
authorPhoto: e.authorPhoto,
|
|
359
369
|
moderationStatus: "approved",
|
|
360
370
|
moderationModel: e.moderationModel ?? null,
|
|
361
|
-
createdAt:
|
|
362
|
-
updatedAt:
|
|
363
|
-
}), await
|
|
371
|
+
createdAt: w(),
|
|
372
|
+
updatedAt: w()
|
|
373
|
+
}), await T(o(s), { content: e.content }), n;
|
|
364
374
|
}
|
|
365
|
-
async function
|
|
366
|
-
let n =
|
|
367
|
-
await Promise.all([
|
|
375
|
+
async function N(e, t) {
|
|
376
|
+
let n = ve(t.content, t.attachments);
|
|
377
|
+
await Promise.all([D(a(e), {
|
|
368
378
|
title: t.title,
|
|
369
379
|
...n,
|
|
370
|
-
previewImage: n.previewImage ? n.previewImage :
|
|
371
|
-
videoUrl: n.videoUrl ? n.videoUrl :
|
|
380
|
+
previewImage: n.previewImage ? n.previewImage : f(),
|
|
381
|
+
videoUrl: n.videoUrl ? n.videoUrl : f(),
|
|
372
382
|
tags: t.tags ?? [],
|
|
373
|
-
category: t.category ? t.category :
|
|
383
|
+
category: t.category ? t.category : f(),
|
|
374
384
|
attachments: t.attachments ?? [],
|
|
375
|
-
updatedAt:
|
|
376
|
-
}),
|
|
385
|
+
updatedAt: w()
|
|
386
|
+
}), T(o(e), { content: t.content }, { merge: !0 })]);
|
|
377
387
|
}
|
|
378
|
-
async function
|
|
379
|
-
let [i,
|
|
388
|
+
async function P(e) {
|
|
389
|
+
let [i, s] = await Promise.all([g(a(e)), g(o(e))]);
|
|
380
390
|
if (!i.exists()) return;
|
|
381
|
-
let
|
|
382
|
-
for (let e = 0; e <
|
|
383
|
-
let n =
|
|
384
|
-
|
|
391
|
+
let l = i.data(), u = s.exists() ? String(s.data().content ?? "") : "", f = await _(c(e));
|
|
392
|
+
for (let e = 0; e < f.docs.length; e += 450) {
|
|
393
|
+
let n = k(t);
|
|
394
|
+
f.docs.slice(e, e + 450).forEach((e) => n.delete(e.ref)), await n.commit();
|
|
385
395
|
}
|
|
386
|
-
let
|
|
387
|
-
for (let t of
|
|
388
|
-
|
|
389
|
-
let n =
|
|
390
|
-
n && n !== e &&
|
|
396
|
+
let p = L(n), m = /* @__PURE__ */ new Set(), h = /* @__PURE__ */ new Set();
|
|
397
|
+
for (let t of l.attachments ?? []) if (t?.path) {
|
|
398
|
+
m.add(t.path);
|
|
399
|
+
let n = he(t.path);
|
|
400
|
+
n && n !== e && h.add(n);
|
|
391
401
|
}
|
|
392
|
-
for (let t of pe(
|
|
402
|
+
for (let t of pe(u)) {
|
|
393
403
|
let n = me(t);
|
|
394
404
|
if (!n) continue;
|
|
395
|
-
|
|
405
|
+
m.add(n);
|
|
396
406
|
let r = n.replace(/\/images\/([^/]+)$/, "/images/thumbnails/$1").replace(/\.[^.]+$/, ".jpg");
|
|
397
|
-
r !== n && r.includes("/images/thumbnails/") &&
|
|
407
|
+
r !== n && r.includes("/images/thumbnails/") && m.add(r);
|
|
398
408
|
let i = n.match(/^(.*\/images\/)([^/]+)\.[^.]+$/);
|
|
399
|
-
i && !n.includes("/thumbnails/") &&
|
|
400
|
-
let a =
|
|
401
|
-
a && a !== e &&
|
|
409
|
+
i && !n.includes("/thumbnails/") && m.add(`${i[1]}thumbnails/${i[2]}.jpg`);
|
|
410
|
+
let a = he(n);
|
|
411
|
+
a && a !== e && h.add(a);
|
|
402
412
|
}
|
|
403
|
-
await
|
|
404
|
-
for (let e of
|
|
405
|
-
await
|
|
413
|
+
await d(o(e)), await ye(R(p, `${r()}/posts/${e}`));
|
|
414
|
+
for (let e of h) await ye(R(p, `${r()}/posts/${e}`));
|
|
415
|
+
await be(p, m), await d(a(e));
|
|
406
416
|
}
|
|
407
417
|
return {
|
|
408
|
-
createPostId:
|
|
409
|
-
getPosts:
|
|
410
|
-
getPost:
|
|
411
|
-
getPostBySlug:
|
|
412
|
-
getAdjacentPosts:
|
|
413
|
-
createPost:
|
|
414
|
-
updatePost:
|
|
415
|
-
deletePost:
|
|
418
|
+
createPostId: m,
|
|
419
|
+
getPosts: v,
|
|
420
|
+
getPost: x,
|
|
421
|
+
getPostBySlug: A,
|
|
422
|
+
getAdjacentPosts: j,
|
|
423
|
+
createPost: M,
|
|
424
|
+
updatePost: N,
|
|
425
|
+
deletePost: P
|
|
416
426
|
};
|
|
417
427
|
}
|
|
418
428
|
//#endregion
|
|
419
429
|
//#region src/composables/useMemiBoardComments.ts
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
430
|
+
var Se = 10, Ce = 5;
|
|
431
|
+
function we(n, a = {}) {
|
|
432
|
+
let o = U(), s = l(), c = () => o.collectionPrefix, d = e(() => typeof n == "string" ? n : n.value), f = r([]), h = r([]), g = r(a.subscribe !== !1), b = r([]), T = r(!1), D = r(a.subscribe !== !1), A = r(a.subscribe !== !1), j = null, M, N;
|
|
433
|
+
function P(e) {
|
|
434
|
+
return e.createdAt?.toMillis?.() ?? 0;
|
|
435
|
+
}
|
|
436
|
+
let ee = e(() => {
|
|
437
|
+
let e = /* @__PURE__ */ new Map();
|
|
438
|
+
for (let t of [
|
|
439
|
+
...f.value,
|
|
440
|
+
...h.value,
|
|
441
|
+
...b.value
|
|
442
|
+
]) t.id && e.set(t.id, t);
|
|
443
|
+
return [...e.values()].sort((e, t) => P(e) - P(t) || String(e.id).localeCompare(String(t.id)));
|
|
444
|
+
});
|
|
445
|
+
i(d, async (e) => {
|
|
446
|
+
if (b.value = [], f.value = [], h.value = [], j = null, clearTimeout(M), M = void 0, N?.(), N = void 0, D.value = a.subscribe !== !1, a.subscribe !== !1) {
|
|
447
|
+
g.value = !0, A.value = !0;
|
|
448
|
+
try {
|
|
449
|
+
let t = _(C(u(s, `${c()}Posts`, e, "comments"), S("createdAt", "desc"), S(m(), "desc")));
|
|
450
|
+
if (await Promise.all([t.then((t) => {
|
|
451
|
+
if (d.value !== e) return;
|
|
452
|
+
let n = t.docs.map((e) => ({
|
|
453
|
+
id: e.id,
|
|
454
|
+
...e.data()
|
|
455
|
+
}));
|
|
456
|
+
h.value = n.filter((e) => e.parentId === void 0);
|
|
457
|
+
}), F()]), d.value !== e) return;
|
|
458
|
+
} finally {
|
|
459
|
+
d.value === e && (g.value = !1, A.value = !1);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}, { immediate: !0 });
|
|
463
|
+
function te() {
|
|
464
|
+
N?.(), N = x(C(u(s, `${c()}Posts`, d.value, "comments"), O("parentId", "==", null), S("createdAt", "desc"), S(m(), "desc"), y(1)), (e) => {
|
|
465
|
+
let t = new Map(f.value.map((e) => [e.id, e]));
|
|
466
|
+
for (let n of e.docChanges()) n.type !== "removed" && t.set(n.doc.id, {
|
|
467
|
+
id: n.doc.id,
|
|
468
|
+
...n.doc.data()
|
|
469
|
+
});
|
|
470
|
+
f.value = [...t.values()];
|
|
471
|
+
}, (e) => {
|
|
472
|
+
console.error("[memi-board:comments] onSnapshot failed", e);
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
async function F() {
|
|
476
|
+
if (!(T.value || !D.value)) {
|
|
477
|
+
T.value = !0;
|
|
478
|
+
try {
|
|
479
|
+
let e = [
|
|
480
|
+
O("parentId", "==", null),
|
|
481
|
+
S("createdAt", "asc"),
|
|
482
|
+
S(m(), "asc"),
|
|
483
|
+
...j ? [E(j)] : [],
|
|
484
|
+
y(11)
|
|
485
|
+
], t = await _(C(u(s, `${c()}Posts`, d.value, "comments"), ...e)), n = t.docs.slice(0, Se), r = n.map((e) => ({
|
|
486
|
+
id: e.id,
|
|
487
|
+
...e.data()
|
|
488
|
+
})), i = new Set(b.value.map((e) => e.id));
|
|
489
|
+
b.value.push(...r.filter((e) => !i.has(e.id))), j = n.at(-1) ?? j, t.size <= Se && (D.value = !1, M = setTimeout(() => {
|
|
490
|
+
M = void 0, te();
|
|
491
|
+
}, 1e3));
|
|
492
|
+
} finally {
|
|
493
|
+
T.value = !1;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
t(() => {
|
|
498
|
+
clearTimeout(M), N?.();
|
|
499
|
+
});
|
|
500
|
+
async function I(e) {
|
|
501
|
+
let t = k(s), n = c(), r = p(u(s, `${n}Posts`, d.value, "comments"));
|
|
502
|
+
t.set(r, {
|
|
503
|
+
postId: d.value,
|
|
504
|
+
body: e.body,
|
|
505
|
+
authorUid: e.authorUid,
|
|
506
|
+
authorName: e.authorName,
|
|
507
|
+
authorPhoto: e.authorPhoto,
|
|
508
|
+
moderationStatus: "approved",
|
|
509
|
+
parentId: null,
|
|
510
|
+
rootId: r.id,
|
|
511
|
+
depth: 0,
|
|
512
|
+
replyToUid: null,
|
|
513
|
+
replyToName: null,
|
|
514
|
+
replyCount: 0,
|
|
515
|
+
isReply: !1,
|
|
516
|
+
createdAt: w()
|
|
517
|
+
}), t.update(p(s, `${n}Posts`, d.value), { commentCount: v(1) }), await t.commit();
|
|
518
|
+
}
|
|
519
|
+
async function L(e) {
|
|
520
|
+
if (!e.parent.id) throw Error("답글 대상 댓글을 찾을 수 없습니다.");
|
|
521
|
+
let t = k(s), n = c(), r = u(s, `${n}Posts`, d.value, "comments"), i = p(r), a = e.parent.parentId == null ? e.parent.id : e.parent.rootId;
|
|
522
|
+
if (!a) throw Error("댓글 스레드를 찾을 수 없습니다.");
|
|
523
|
+
let o = Math.min((e.parent.depth ?? 0) + 1, 2);
|
|
424
524
|
t.set(i, {
|
|
425
|
-
postId:
|
|
525
|
+
postId: d.value,
|
|
426
526
|
body: e.body,
|
|
427
527
|
authorUid: e.authorUid,
|
|
428
528
|
authorName: e.authorName,
|
|
429
529
|
authorPhoto: e.authorPhoto,
|
|
430
530
|
moderationStatus: "approved",
|
|
431
|
-
|
|
432
|
-
|
|
531
|
+
parentId: e.parent.id,
|
|
532
|
+
rootId: a,
|
|
533
|
+
depth: o,
|
|
534
|
+
replyToUid: e.parent.authorUid,
|
|
535
|
+
replyToName: e.parent.authorName,
|
|
536
|
+
replyCount: 0,
|
|
537
|
+
isReply: !0,
|
|
538
|
+
createdAt: w()
|
|
539
|
+
}), t.update(p(r, a), { replyCount: v(1) }), t.update(p(s, `${n}Posts`, d.value), { commentCount: v(1) }), await t.commit();
|
|
433
540
|
}
|
|
434
|
-
async function
|
|
435
|
-
|
|
436
|
-
|
|
541
|
+
async function ne(e) {
|
|
542
|
+
if (!e.id) return;
|
|
543
|
+
if (e.parentId == null && (e.replyCount ?? 0) > 0) throw Error("답글이 있는 댓글은 현재 삭제할 수 없습니다.");
|
|
544
|
+
let t = k(s), n = c();
|
|
545
|
+
t.delete(p(s, `${n}Posts`, d.value, "comments", e.id)), e.parentId && e.rootId && t.update(p(s, `${n}Posts`, d.value, "comments", e.rootId), { replyCount: v(-1) }), t.update(p(s, `${n}Posts`, d.value), { commentCount: v(-1) }), await t.commit(), b.value = b.value.filter((t) => t.id !== e.id), h.value = h.value.filter((t) => t.id !== e.id), f.value = f.value.filter((t) => t.id !== e.id);
|
|
437
546
|
}
|
|
438
547
|
return {
|
|
439
|
-
comments:
|
|
440
|
-
|
|
441
|
-
|
|
548
|
+
comments: ee,
|
|
549
|
+
commentsPending: e(() => A.value || g.value),
|
|
550
|
+
hasMore: D,
|
|
551
|
+
loadingMore: T,
|
|
552
|
+
loadMore: F,
|
|
553
|
+
addComment: I,
|
|
554
|
+
addReply: L,
|
|
555
|
+
deleteComment: ne
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
function Te(e, n) {
|
|
559
|
+
let i = U(), a = l(), o = r([]), s = r(!1), c = r(!1), d = r(!0), f = null, p;
|
|
560
|
+
function h(e) {
|
|
561
|
+
return e.sort((e, t) => (e.createdAt?.toMillis?.() ?? 0) - (t.createdAt?.toMillis?.() ?? 0) || String(e.id).localeCompare(String(t.id)));
|
|
562
|
+
}
|
|
563
|
+
function g() {
|
|
564
|
+
p ||= x(C(u(a, `${i.collectionPrefix}Posts`, e, "comments"), O("rootId", "==", n), O("isReply", "==", !0), S("createdAt", "desc"), S(m(), "desc"), y(1)), (e) => {
|
|
565
|
+
let t = new Map(o.value.map((e) => [e.id, e]));
|
|
566
|
+
for (let n of e.docChanges()) n.type !== "removed" && t.set(n.doc.id, {
|
|
567
|
+
id: n.doc.id,
|
|
568
|
+
...n.doc.data()
|
|
569
|
+
});
|
|
570
|
+
o.value = h([...t.values()]);
|
|
571
|
+
}, (e) => {
|
|
572
|
+
console.error("[memi-board:replies] onSnapshot failed", e);
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
async function v() {
|
|
576
|
+
if (!(s.value || !d.value)) {
|
|
577
|
+
s.value = !0;
|
|
578
|
+
try {
|
|
579
|
+
let t = [
|
|
580
|
+
O("rootId", "==", n),
|
|
581
|
+
O("isReply", "==", !0),
|
|
582
|
+
S("createdAt", "asc"),
|
|
583
|
+
S(m(), "asc"),
|
|
584
|
+
...f ? [E(f)] : [],
|
|
585
|
+
y(6)
|
|
586
|
+
], r = await _(C(u(a, `${i.collectionPrefix}Posts`, e, "comments"), ...t)), s = new Set(o.value.map((e) => e.id)), l = r.docs.slice(0, Ce), p = l.map((e) => ({
|
|
587
|
+
id: e.id,
|
|
588
|
+
...e.data()
|
|
589
|
+
})).filter((e) => !s.has(e.id));
|
|
590
|
+
o.value = h([...o.value, ...p]), f = l.at(-1) ?? f, c.value = !0, r.size <= Ce && (d.value = !1, g());
|
|
591
|
+
} finally {
|
|
592
|
+
s.value = !1;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
function b() {
|
|
597
|
+
p?.(), p = void 0, o.value = [], f = null, c.value = !1, d.value = !0;
|
|
598
|
+
}
|
|
599
|
+
async function w() {
|
|
600
|
+
b(), await v();
|
|
601
|
+
}
|
|
602
|
+
return t(() => p?.()), {
|
|
603
|
+
replies: o,
|
|
604
|
+
loading: s,
|
|
605
|
+
loaded: c,
|
|
606
|
+
hasMore: d,
|
|
607
|
+
loadMore: v,
|
|
608
|
+
refresh: w
|
|
442
609
|
};
|
|
443
610
|
}
|
|
444
611
|
//#endregion
|
|
445
612
|
//#region src/utils/moderation-prompt.ts
|
|
446
|
-
var
|
|
613
|
+
var Ee = "당신은 게시판 콘텐츠를 심사하는 엄격한 모더레이터입니다.\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은 \"\"", De = {
|
|
447
614
|
abuse: "욕설·비속어·모욕",
|
|
448
615
|
spam: "스팸·광고",
|
|
449
616
|
adult: "선정적·성인 내용",
|
|
@@ -458,10 +625,10 @@ function Z(e) {
|
|
|
458
625
|
}
|
|
459
626
|
let t = (e.reason || "").trim();
|
|
460
627
|
if (t) return t;
|
|
461
|
-
let n = e.category && e.category !== "none" ?
|
|
628
|
+
let n = e.category && e.category !== "none" ? De[e.category] || e.category : "";
|
|
462
629
|
return n ? `${n}이(가) 포함되어 게시할 수 없습니다. 내용을 수정해 주세요.` : "게시할 수 없는 내용이 포함되어 있습니다. 내용을 수정해 주세요.";
|
|
463
630
|
}
|
|
464
|
-
var
|
|
631
|
+
var Oe = [
|
|
465
632
|
"씨발",
|
|
466
633
|
"시발",
|
|
467
634
|
"시이발",
|
|
@@ -487,10 +654,10 @@ var Se = [
|
|
|
487
654
|
"bitch",
|
|
488
655
|
"asshole"
|
|
489
656
|
];
|
|
490
|
-
function
|
|
657
|
+
function ke(e) {
|
|
491
658
|
return new RegExp(e.join("|"), "i");
|
|
492
659
|
}
|
|
493
|
-
var
|
|
660
|
+
var Ae = /* @__PURE__ */ new Set([
|
|
494
661
|
"none",
|
|
495
662
|
"abuse",
|
|
496
663
|
"spam",
|
|
@@ -498,7 +665,7 @@ var we = /* @__PURE__ */ new Set([
|
|
|
498
665
|
"violence",
|
|
499
666
|
"other"
|
|
500
667
|
]);
|
|
501
|
-
function
|
|
668
|
+
function je(e) {
|
|
502
669
|
let t = e.trim();
|
|
503
670
|
if (!t) return null;
|
|
504
671
|
let n;
|
|
@@ -516,7 +683,7 @@ function Te(e) {
|
|
|
516
683
|
if (typeof n != "object" || !n) return null;
|
|
517
684
|
let r = n;
|
|
518
685
|
if (typeof r.flagged != "boolean") return null;
|
|
519
|
-
let i = typeof r.category == "string" &&
|
|
686
|
+
let i = typeof r.category == "string" && Ae.has(r.category) ? r.category : r.flagged ? "other" : "none", a = typeof r.reason == "string" ? r.reason : "";
|
|
520
687
|
return {
|
|
521
688
|
flagged: r.flagged,
|
|
522
689
|
category: i,
|
|
@@ -525,15 +692,15 @@ function Te(e) {
|
|
|
525
692
|
}
|
|
526
693
|
//#endregion
|
|
527
694
|
//#region src/composables/useMemiBoardModeration.ts
|
|
528
|
-
var
|
|
695
|
+
var Me = {
|
|
529
696
|
flagged: !1,
|
|
530
697
|
category: "none",
|
|
531
698
|
reason: "",
|
|
532
699
|
via: "empty"
|
|
533
700
|
};
|
|
534
|
-
function
|
|
535
|
-
let e =
|
|
536
|
-
function
|
|
701
|
+
function Ne() {
|
|
702
|
+
let e = U().moderation ?? {}, t = s(), { isWriteRestricted: n, restrictedMessage: r, recordContentModerationBlock: i } = X(), a = ke([...Oe, ...e.localBlocklist ?? []]);
|
|
703
|
+
function o(e) {
|
|
537
704
|
let t = e.match(a);
|
|
538
705
|
return t ? {
|
|
539
706
|
flagged: !0,
|
|
@@ -547,21 +714,21 @@ function De() {
|
|
|
547
714
|
} : null;
|
|
548
715
|
}
|
|
549
716
|
function c() {
|
|
550
|
-
let n = e.model ?? "gemini-3.5-flash-lite", r = e.useLimitedUseAppCheckTokens !== !1, i =
|
|
551
|
-
backend: new
|
|
717
|
+
let n = e.model ?? "gemini-3.5-flash-lite", r = e.useLimitedUseAppCheckTokens !== !1, i = ae(t, {
|
|
718
|
+
backend: new ie(),
|
|
552
719
|
useLimitedUseAppCheckTokens: r
|
|
553
720
|
});
|
|
554
|
-
return
|
|
721
|
+
return oe(i, {
|
|
555
722
|
model: n,
|
|
556
|
-
systemInstruction:
|
|
723
|
+
systemInstruction: Ee,
|
|
557
724
|
generationConfig: {
|
|
558
725
|
temperature: 0,
|
|
559
726
|
maxOutputTokens: 256,
|
|
560
727
|
responseMimeType: "application/json",
|
|
561
|
-
responseSchema:
|
|
562
|
-
flagged:
|
|
563
|
-
category:
|
|
564
|
-
reason:
|
|
728
|
+
responseSchema: B.object({ properties: {
|
|
729
|
+
flagged: B.boolean(),
|
|
730
|
+
category: B.string(),
|
|
731
|
+
reason: B.string()
|
|
565
732
|
} })
|
|
566
733
|
}
|
|
567
734
|
});
|
|
@@ -610,17 +777,17 @@ function De() {
|
|
|
610
777
|
};
|
|
611
778
|
let i = t.trim();
|
|
612
779
|
if (!i) return {
|
|
613
|
-
...
|
|
780
|
+
...Me,
|
|
614
781
|
via: "empty"
|
|
615
782
|
};
|
|
616
|
-
let a =
|
|
783
|
+
let a = o(i);
|
|
617
784
|
if (a) return d(a);
|
|
618
785
|
if (e.enabled === !1) return {
|
|
619
|
-
...
|
|
786
|
+
...Me,
|
|
620
787
|
via: "disabled"
|
|
621
788
|
};
|
|
622
789
|
try {
|
|
623
|
-
let e =
|
|
790
|
+
let e = je((await c().generateContent(`심사할 텍스트:\n"""${i.slice(0, 4e3)}"""`)).response.text());
|
|
624
791
|
if (!e) return l();
|
|
625
792
|
let t = {
|
|
626
793
|
flagged: e.flagged,
|
|
@@ -645,14 +812,14 @@ function De() {
|
|
|
645
812
|
via: "restricted"
|
|
646
813
|
};
|
|
647
814
|
if (e.enabled === !1 || !e.moderateImages) return {
|
|
648
|
-
...
|
|
815
|
+
...Me,
|
|
649
816
|
via: "disabled"
|
|
650
817
|
};
|
|
651
818
|
try {
|
|
652
819
|
let e = await new Promise((e, n) => {
|
|
653
820
|
let r = new FileReader();
|
|
654
821
|
r.onload = () => e(r.result.split(",")[1] ?? ""), r.onerror = n, r.readAsDataURL(t);
|
|
655
|
-
}), n =
|
|
822
|
+
}), n = je((await c().generateContent([{ inlineData: {
|
|
656
823
|
mimeType: t.type || "image/jpeg",
|
|
657
824
|
data: e
|
|
658
825
|
} }, "이 이미지를 심사해 주세요."])).response.text());
|
|
@@ -679,7 +846,7 @@ function De() {
|
|
|
679
846
|
}
|
|
680
847
|
//#endregion
|
|
681
848
|
//#region src/utils/compressImage.ts
|
|
682
|
-
async function
|
|
849
|
+
async function Pe(e, { maxWidth: t = 400, quality: n = .8 } = {}) {
|
|
683
850
|
return new Promise((r, i) => {
|
|
684
851
|
let a = new Image(), o = URL.createObjectURL(e);
|
|
685
852
|
a.onload = () => {
|
|
@@ -699,11 +866,11 @@ async function Oe(e, { maxWidth: t = 400, quality: n = .8 } = {}) {
|
|
|
699
866
|
}
|
|
700
867
|
//#endregion
|
|
701
868
|
//#region src/composables/useMemiBoardStorage.ts
|
|
702
|
-
var
|
|
703
|
-
function
|
|
704
|
-
let e =
|
|
869
|
+
var Fe = 5242880;
|
|
870
|
+
function Ie() {
|
|
871
|
+
let e = U(), t = s(), n = () => e.collectionPrefix;
|
|
705
872
|
function r(e, r, i) {
|
|
706
|
-
let a =
|
|
873
|
+
let a = L(t), o = `${n()}/posts/${r}/attachments/${Date.now()}-${fe(e.name)}`, s = R(a, o), c = re(s, e, {
|
|
707
874
|
contentType: e.type || "application/octet-stream",
|
|
708
875
|
contentDisposition: `attachment; filename*=UTF-8''${encodeURIComponent(e.name)}`
|
|
709
876
|
});
|
|
@@ -712,7 +879,7 @@ function Ae() {
|
|
|
712
879
|
c.on("state_changed", (e) => {
|
|
713
880
|
i && e.totalBytes > 0 && i(e.bytesTransferred / e.totalBytes);
|
|
714
881
|
}, n, async () => {
|
|
715
|
-
let n = await
|
|
882
|
+
let n = await I(s);
|
|
716
883
|
t({
|
|
717
884
|
name: e.name,
|
|
718
885
|
url: n,
|
|
@@ -726,130 +893,199 @@ function Ae() {
|
|
|
726
893
|
};
|
|
727
894
|
}
|
|
728
895
|
async function i(e) {
|
|
729
|
-
let n =
|
|
730
|
-
await
|
|
896
|
+
let n = L(t);
|
|
897
|
+
await F(R(n, e.path)).catch(() => {});
|
|
731
898
|
}
|
|
732
899
|
async function a(e, r) {
|
|
733
900
|
if (!e.type.startsWith("image/")) throw Error("이미지 파일만 업로드할 수 있습니다.");
|
|
734
|
-
if (e.size > 5242880) throw Error(`이미지는 ${
|
|
735
|
-
let i =
|
|
736
|
-
await
|
|
737
|
-
let d = await
|
|
901
|
+
if (e.size > 5242880) throw Error(`이미지는 ${Fe / 1024 / 1024}MB 이하여야 합니다.`);
|
|
902
|
+
let i = L(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 = R(i, l);
|
|
903
|
+
await z(u, e, { contentType: e.type || "image/png" });
|
|
904
|
+
let d = await I(u), f = await Pe(e, {
|
|
738
905
|
maxWidth: 400,
|
|
739
906
|
quality: .8
|
|
740
|
-
}), p = `${n()}/posts/${c}/images/thumbnails/${s}.jpg`, m =
|
|
741
|
-
return await
|
|
907
|
+
}), p = `${n()}/posts/${c}/images/thumbnails/${s}.jpg`, m = R(i, p);
|
|
908
|
+
return await z(m, f, { contentType: "image/jpeg" }), {
|
|
742
909
|
originalUrl: d,
|
|
743
910
|
originalPath: l,
|
|
744
|
-
thumbnailUrl: await
|
|
911
|
+
thumbnailUrl: await I(m),
|
|
745
912
|
thumbnailPath: p
|
|
746
913
|
};
|
|
747
914
|
}
|
|
748
|
-
async function
|
|
749
|
-
let n =
|
|
750
|
-
await Promise.all([
|
|
915
|
+
async function o(e) {
|
|
916
|
+
let n = L(t);
|
|
917
|
+
await Promise.all([F(R(n, e.originalPath)).catch(() => {}), F(R(n, e.thumbnailPath)).catch(() => {})]);
|
|
751
918
|
}
|
|
752
919
|
return {
|
|
753
920
|
uploadAttachment: r,
|
|
754
921
|
deleteAttachment: i,
|
|
755
922
|
uploadEditorImage: a,
|
|
756
|
-
deleteEditorImage:
|
|
923
|
+
deleteEditorImage: o
|
|
757
924
|
};
|
|
758
925
|
}
|
|
759
926
|
//#endregion
|
|
760
927
|
//#region src/composables/useMemiBoardSettings.ts
|
|
761
|
-
var
|
|
928
|
+
var Le = [
|
|
762
929
|
{
|
|
763
930
|
id: "free",
|
|
764
931
|
label: "자유",
|
|
765
932
|
listView: "default",
|
|
933
|
+
writeRole: "user",
|
|
766
934
|
order: 0
|
|
767
935
|
},
|
|
768
936
|
{
|
|
769
937
|
id: "notice",
|
|
770
938
|
label: "공지",
|
|
771
939
|
listView: "default",
|
|
940
|
+
writeRole: "admin",
|
|
772
941
|
order: 1
|
|
773
942
|
},
|
|
774
943
|
{
|
|
775
944
|
id: "question",
|
|
776
945
|
label: "질문",
|
|
777
946
|
listView: "default",
|
|
947
|
+
writeRole: "user",
|
|
778
948
|
order: 2
|
|
779
949
|
}
|
|
780
950
|
];
|
|
781
|
-
function
|
|
782
|
-
let t =
|
|
951
|
+
function Re() {
|
|
952
|
+
let t = U(), n = l(), { isSignedIn: r } = X(), i = e(() => p(n, `${t.collectionPrefix}Settings`, "config")), o = e(() => u(n, `${t.collectionPrefix}Settings`, "config", "categories")), s = e(() => C(o.value, S("order", "asc"))), { data: c, pending: f } = a(s, { ssrKey: `${t.collectionPrefix}Settings/config/categories` }), m = e(() => c.value.map((e, t) => ({
|
|
783
953
|
...e,
|
|
784
954
|
id: e.id,
|
|
785
955
|
order: typeof e.order == "number" ? e.order : t,
|
|
786
|
-
listView: e.listView ?? "default"
|
|
956
|
+
listView: e.listView ?? "default",
|
|
957
|
+
writeRole: e.writeRole ?? "user"
|
|
787
958
|
})));
|
|
788
959
|
function h(e) {
|
|
789
960
|
if (e) return m.value.find((t) => t.id === e)?.label ?? e;
|
|
790
961
|
}
|
|
791
962
|
async function g(e, t = e.order ?? 0) {
|
|
792
963
|
if (!r.value) throw Error("로그인이 필요합니다.");
|
|
793
|
-
let n = e.id.trim(),
|
|
794
|
-
if (!n || !
|
|
795
|
-
await
|
|
796
|
-
label:
|
|
964
|
+
let n = e.id.trim(), a = e.label.trim();
|
|
965
|
+
if (!n || !a) throw Error("카테고리 ID와 이름을 입력해 주세요.");
|
|
966
|
+
await T(i.value, { updatedAt: w() }, { merge: !0 }), await T(p(o.value, n), {
|
|
967
|
+
label: a,
|
|
797
968
|
listView: e.listView ?? "default",
|
|
969
|
+
writeRole: e.writeRole ?? "user",
|
|
798
970
|
order: t,
|
|
799
|
-
updatedAt:
|
|
971
|
+
updatedAt: w()
|
|
800
972
|
}, { merge: !0 });
|
|
801
973
|
}
|
|
802
974
|
async function _(e) {
|
|
803
975
|
if (!r.value) throw Error("로그인이 필요합니다.");
|
|
804
|
-
let t =
|
|
805
|
-
t.set(
|
|
976
|
+
let t = k(n);
|
|
977
|
+
t.set(i.value, { updatedAt: w() }, { merge: !0 }), e.forEach((e, n) => {
|
|
806
978
|
let r = e.id.trim(), i = e.label.trim();
|
|
807
979
|
if (!r || !i) throw Error("카테고리 ID와 이름을 입력해 주세요.");
|
|
808
|
-
t.set(
|
|
980
|
+
t.set(p(o.value, r), {
|
|
809
981
|
label: i,
|
|
810
982
|
listView: e.listView ?? "default",
|
|
983
|
+
writeRole: e.writeRole ?? "user",
|
|
811
984
|
order: n,
|
|
812
|
-
updatedAt:
|
|
985
|
+
updatedAt: w()
|
|
813
986
|
}, { merge: !0 });
|
|
814
987
|
}), await t.commit();
|
|
815
988
|
}
|
|
816
989
|
async function v(e) {
|
|
817
990
|
if (!r.value) throw Error("로그인이 필요합니다.");
|
|
818
|
-
await
|
|
991
|
+
await d(p(o.value, e));
|
|
819
992
|
}
|
|
820
|
-
async function
|
|
993
|
+
async function y() {
|
|
821
994
|
return Promise.resolve();
|
|
822
995
|
}
|
|
823
|
-
async function
|
|
996
|
+
async function b(e) {
|
|
824
997
|
let t = e.trim();
|
|
825
998
|
if (!t) throw Error("카테고리 이름을 입력해 주세요.");
|
|
826
999
|
if (!r.value) throw Error("로그인이 필요합니다.");
|
|
827
1000
|
let n = m.value.find((e) => e.label === t);
|
|
828
1001
|
if (n) return n.id;
|
|
829
|
-
let i =
|
|
1002
|
+
let i = de(t) || `cat-${Date.now()}`, a = i, o = 2, s = new Set(m.value.map((e) => e.id));
|
|
830
1003
|
for (; s.has(a);) a = `${i}-${o++}`;
|
|
831
1004
|
return await g({
|
|
832
1005
|
id: a,
|
|
833
1006
|
label: t,
|
|
834
1007
|
listView: "default",
|
|
1008
|
+
writeRole: "user",
|
|
835
1009
|
order: m.value.length
|
|
836
1010
|
}), a;
|
|
837
1011
|
}
|
|
838
1012
|
return {
|
|
839
1013
|
categories: m,
|
|
840
1014
|
categoryLabel: h,
|
|
841
|
-
settingsPending:
|
|
842
|
-
ensureSettings:
|
|
843
|
-
addCategory:
|
|
1015
|
+
settingsPending: f,
|
|
1016
|
+
ensureSettings: y,
|
|
1017
|
+
addCategory: b,
|
|
844
1018
|
saveCategory: g,
|
|
845
1019
|
saveCategories: _,
|
|
846
1020
|
deleteCategory: v,
|
|
847
|
-
DEFAULT_CATEGORIES:
|
|
1021
|
+
DEFAULT_CATEGORIES: Le
|
|
1022
|
+
};
|
|
1023
|
+
}
|
|
1024
|
+
//#endregion
|
|
1025
|
+
//#region src/composables/useMemiBoardUsers.ts
|
|
1026
|
+
var ze = [
|
|
1027
|
+
{
|
|
1028
|
+
value: "admin",
|
|
1029
|
+
label: "관리자",
|
|
1030
|
+
description: "사용자 역할, 게시판 설정과 모든 콘텐츠를 관리합니다."
|
|
1031
|
+
},
|
|
1032
|
+
{
|
|
1033
|
+
value: "staff",
|
|
1034
|
+
label: "스태프",
|
|
1035
|
+
description: "모든 게시글과 댓글을 관리하지만 사용자 역할과 게시판 설정은 바꿀 수 없습니다."
|
|
1036
|
+
},
|
|
1037
|
+
{
|
|
1038
|
+
value: "user",
|
|
1039
|
+
label: "사용자",
|
|
1040
|
+
description: "글과 댓글을 작성하고 본인이 작성한 콘텐츠만 수정·삭제할 수 있습니다."
|
|
1041
|
+
}
|
|
1042
|
+
];
|
|
1043
|
+
function Be() {
|
|
1044
|
+
let e = U(), t = l(), n = `${e.collectionPrefix}Users`, r = a(u(t, n), { ssrKey: `${n}-management` });
|
|
1045
|
+
async function i(e, r) {
|
|
1046
|
+
if (!ze.some((e) => e.value === r)) throw Error("지원하지 않는 역할입니다.");
|
|
1047
|
+
await D(p(t, n, e), {
|
|
1048
|
+
role: r,
|
|
1049
|
+
updatedAt: w()
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
return {
|
|
1053
|
+
users: r,
|
|
1054
|
+
usersPending: r.pending,
|
|
1055
|
+
updateUserRole: i
|
|
848
1056
|
};
|
|
849
1057
|
}
|
|
850
1058
|
//#endregion
|
|
851
1059
|
//#region src/data/versionHistory.ts
|
|
852
|
-
var
|
|
1060
|
+
var Ve = [
|
|
1061
|
+
{
|
|
1062
|
+
version: "0.9.0",
|
|
1063
|
+
date: "2026-08-04",
|
|
1064
|
+
highlights: [
|
|
1065
|
+
"댓글에 답글을 달고 대화 흐름을 최대 2단계로 볼 수 있어요",
|
|
1066
|
+
"댓글 10개·답글 5개 단위로 이어서 보고 마지막부터 새 댓글을 실시간으로 받아요",
|
|
1067
|
+
"더보기 버튼이 화면에 들어오면 로딩 표시와 함께 다음 댓글을 자동으로 불러와요"
|
|
1068
|
+
]
|
|
1069
|
+
},
|
|
1070
|
+
{
|
|
1071
|
+
version: "0.8.0",
|
|
1072
|
+
date: "2026-08-04",
|
|
1073
|
+
highlights: [
|
|
1074
|
+
"최근 댓글 5개는 실시간으로 보고 이전 댓글은 5개씩 불러올 수 있어요",
|
|
1075
|
+
"댓글 시간이 상대시간으로 표시되고 전체 시간은 툴팁으로 확인할 수 있어요",
|
|
1076
|
+
"댓글 작성 영역을 대화 흐름의 맨 아래로 옮겼어요"
|
|
1077
|
+
]
|
|
1078
|
+
},
|
|
1079
|
+
{
|
|
1080
|
+
version: "0.7.0",
|
|
1081
|
+
date: "2026-08-04",
|
|
1082
|
+
highlights: ["카테고리마다 글쓰기 권한을 일반 이상·스태프 이상·관리자만으로 설정할 수 있어요", "게시판 설정 화면의 SSR hydration 경고를 해결했어요"]
|
|
1083
|
+
},
|
|
1084
|
+
{
|
|
1085
|
+
version: "0.6.0",
|
|
1086
|
+
date: "2026-08-04",
|
|
1087
|
+
highlights: ["관리자가 게시판 사용자를 확인하고 관리자·스태프·사용자 역할을 설정할 수 있어요", "스태프는 게시판 설정이나 사용자 역할은 바꾸지 않고 게시글과 댓글을 관리할 수 있어요"]
|
|
1088
|
+
},
|
|
853
1089
|
{
|
|
854
1090
|
version: "0.5.0",
|
|
855
1091
|
date: "2026-08-04",
|
|
@@ -896,7 +1132,7 @@ var Ne = [
|
|
|
896
1132
|
];
|
|
897
1133
|
//#endregion
|
|
898
1134
|
//#region src/utils/formatDate.ts
|
|
899
|
-
function
|
|
1135
|
+
function He(e) {
|
|
900
1136
|
return e?.toDate ? e.toDate().toLocaleDateString("ko-KR") : "";
|
|
901
1137
|
}
|
|
902
1138
|
//#endregion
|
|
@@ -908,7 +1144,7 @@ function $(e) {
|
|
|
908
1144
|
let t = e;
|
|
909
1145
|
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
1146
|
}
|
|
911
|
-
function
|
|
1147
|
+
function Ue(e) {
|
|
912
1148
|
if (!e?.trim()) return "";
|
|
913
1149
|
let t = e.replace(/\r\n/g, "\n").split("\n"), n = [], r = 0, i = !1, a = [], o = null, s = [];
|
|
914
1150
|
function c() {
|
|
@@ -973,6 +1209,6 @@ function Fe(e) {
|
|
|
973
1209
|
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
1210
|
}
|
|
975
1211
|
//#endregion
|
|
976
|
-
export {
|
|
1212
|
+
export { ze as BOARD_USER_ROLES, W as DEFAULT_BLOCK_BAN_DECAY_MS, ce as DEFAULT_BLOCK_BAN_THRESHOLD, Le as DEFAULT_CATEGORIES, Fe as EDITOR_IMAGE_MAX_BYTES, Pe as compressImage, se as configureMemiBoard, G as effectiveModerationBlockCount, pe as extractEditorImageUrls, He as formatDate, J as formatRestrictedUntilLabel, q as isModerationWriteRestricted, K as moderationWriteRestrictedUntilMs, he as postNamespaceFromStoragePath, Ue as renderMarkdownToHtml, de as slugify, me as storagePathFromDownloadUrl, X as useMemiBoardAuth, we as useMemiBoardComments, U as useMemiBoardConfig, Ne as useMemiBoardModeration, xe as useMemiBoardPosts, Te as useMemiBoardReplies, Re as useMemiBoardSettings, Ie as useMemiBoardStorage, Be as useMemiBoardUsers, Ve as versionHistory };
|
|
977
1213
|
|
|
978
1214
|
//# sourceMappingURL=index.js.map
|