memi-board 0.3.1

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 ADDED
@@ -0,0 +1,1121 @@
1
+ import { Fragment as e, computed as t, createBlock as n, createCommentVNode as r, createElementBlock as i, createElementVNode as a, createTextVNode as o, createVNode as s, defineComponent as c, onMounted as l, openBlock as u, reactive as d, ref as f, renderList as p, resolveComponent as m, resolveDynamicComponent as h, toDisplayString as g, unref as _, watch as v, withCtx as y, withModifiers as b } from "vue";
2
+ import { useCollection as x, useCurrentUser as S, useDocument as C, useFirebaseApp as w, useFirebaseAuth as T, useFirestore as E } from "vuefire";
3
+ import { collection as D, deleteDoc as O, doc as k, getDoc as A, getDocs as j, increment as M, limit as N, orderBy as P, query as F, serverTimestamp as I, setDoc as L, startAfter as ee, updateDoc as R, writeBatch as z } from "firebase/firestore";
4
+ import { GoogleAuthProvider as te, OAuthProvider as ne, createUserWithEmailAndPassword as re, signInWithEmailAndPassword as ie, signInWithPopup as ae, signOut as oe, updateProfile as se } from "firebase/auth";
5
+ import { deleteObject as ce, getDownloadURL as le, getStorage as B, listAll as ue, ref as V, uploadBytesResumable as de } from "firebase/storage";
6
+ import { GoogleAIBackend as fe, Schema as H, getAI as pe, getGenerativeModel as me } from "firebase/ai";
7
+ import { RouterLink as he } from "vue-router";
8
+ //#region src/config.ts
9
+ var U = d({
10
+ collectionPrefix: "memiBoard",
11
+ auth: { providers: ["google", "apple"] },
12
+ moderation: {
13
+ enabled: !0,
14
+ model: "gemini-2.5-flash",
15
+ onError: "allow",
16
+ moderateImages: !1,
17
+ localBlocklist: []
18
+ }
19
+ });
20
+ function ge(e) {
21
+ e.collectionPrefix && (U.collectionPrefix = e.collectionPrefix), e.auth && Object.assign(U.auth, e.auth), e.moderation && Object.assign(U.moderation, e.moderation);
22
+ }
23
+ function W() {
24
+ return U;
25
+ }
26
+ //#endregion
27
+ //#region src/composables/useMemiBoardAuth.ts
28
+ function G() {
29
+ let e = W(), n = E(), r = T(), i = S(), a = f("user"), o = f(!0);
30
+ function s(t) {
31
+ return k(n, `${e.collectionPrefix}Users`, t);
32
+ }
33
+ async function c(e) {
34
+ let t = s(e.uid), n = await A(t);
35
+ return n.exists() ? (await R(t, {
36
+ displayName: e.displayName,
37
+ updatedAt: I()
38
+ }), n.data().role ?? "user") : (await L(t, {
39
+ role: "user",
40
+ displayName: e.displayName,
41
+ updatedAt: I()
42
+ }), "user");
43
+ }
44
+ v(i, async (e) => {
45
+ if (o.value = !0, !e) {
46
+ a.value = "user", o.value = !1;
47
+ return;
48
+ }
49
+ try {
50
+ a.value = await c(e);
51
+ } catch {
52
+ a.value = "user";
53
+ } finally {
54
+ o.value = !1;
55
+ }
56
+ }, { immediate: !0 });
57
+ let l = t(() => !!i.value), u = t(() => a.value === "admin");
58
+ function d() {
59
+ if (!r) throw Error("[memi-board] Firebase Auth가 초기화되지 않았습니다.");
60
+ return r;
61
+ }
62
+ async function p() {
63
+ return ae(d(), new te());
64
+ }
65
+ async function m() {
66
+ let e = new ne("apple.com");
67
+ return e.addScope("email"), e.addScope("name"), ae(d(), e);
68
+ }
69
+ async function h(e, t) {
70
+ return ie(d(), e, t);
71
+ }
72
+ async function g(e, t, n) {
73
+ let r = await re(d(), e, t);
74
+ return n && await se(r.user, { displayName: n }), r;
75
+ }
76
+ async function _() {
77
+ await oe(d());
78
+ }
79
+ function y(e) {
80
+ return l.value && (i.value?.uid === e.authorUid || u.value);
81
+ }
82
+ function b(e) {
83
+ return l.value && (i.value?.uid === e.authorUid || u.value);
84
+ }
85
+ return {
86
+ user: i,
87
+ isSignedIn: l,
88
+ isAdmin: u,
89
+ rolePending: o,
90
+ signInWithGoogle: p,
91
+ signInWithApple: m,
92
+ signInWithEmail: h,
93
+ signUpWithEmail: g,
94
+ signOut: _,
95
+ canEdit: y,
96
+ canDelete: y,
97
+ canDeleteComment: b
98
+ };
99
+ }
100
+ //#endregion
101
+ //#region src/utils/slugify.ts
102
+ function _e(e) {
103
+ return e.trim().replace(/\s+/g, "-").replace(/[^a-zA-Z0-9가-힣ㄱ-ㅎㅏ-ㅣ-]/g, "").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
104
+ }
105
+ function ve(e, t = 60) {
106
+ return e.replace(/[^a-zA-Z0-9가-힣ㄱ-ㅎ._-]/g, "-").slice(0, t);
107
+ }
108
+ //#endregion
109
+ //#region src/composables/useMemiBoardPosts.ts
110
+ async function K(e) {
111
+ let t = await ue(e).catch(() => ({
112
+ items: [],
113
+ prefixes: []
114
+ }));
115
+ await Promise.all([...t.items.map((e) => ce(e)), ...t.prefixes.map((e) => K(e))]);
116
+ }
117
+ function q() {
118
+ let e = W(), t = E(), n = w(), r = e.collectionPrefix, i = () => D(t, `${r}Posts`), a = (e) => k(t, `${r}Posts`, e), o = (e) => k(t, `${r}Posts`, e, "body", "main"), s = (e) => D(t, `${r}Posts`, e, "comments");
119
+ async function c(e = {}) {
120
+ let t = e.pageSize ?? 20, n = e.cursor ? [
121
+ P("createdAt", "desc"),
122
+ ee(e.cursor),
123
+ N(t + 1)
124
+ ] : [P("createdAt", "desc"), N(t + 1)], r = await j(F(i(), ...n)), a = r.docs.slice(0, t);
125
+ return {
126
+ posts: a.map((e) => ({
127
+ id: e.id,
128
+ ...e.data()
129
+ })),
130
+ cursor: a[a.length - 1],
131
+ hasMore: r.docs.length > t
132
+ };
133
+ }
134
+ async function l(e) {
135
+ let [t, n] = await Promise.all([A(a(e)), A(o(e))]);
136
+ return t.exists() ? {
137
+ id: t.id,
138
+ ...t.data(),
139
+ content: n.exists() ? n.data().content ?? "" : ""
140
+ } : null;
141
+ }
142
+ async function u(e) {
143
+ let t = _e(e.title) || "post", n = t, r = 1;
144
+ for (; (await A(a(n))).exists();) r++, n = `${t}-${r}`;
145
+ return await Promise.all([L(a(n), {
146
+ title: e.title,
147
+ tags: e.tags ?? [],
148
+ ...e.category ? { category: e.category } : {},
149
+ attachments: e.attachments ?? [],
150
+ commentCount: 0,
151
+ authorUid: e.authorUid,
152
+ authorName: e.authorName,
153
+ authorPhoto: e.authorPhoto,
154
+ moderationStatus: "approved",
155
+ moderationModel: e.moderationModel ?? null,
156
+ createdAt: I(),
157
+ updatedAt: I()
158
+ }), L(o(n), { content: e.content })]), n;
159
+ }
160
+ async function d(e, t) {
161
+ await Promise.all([R(a(e), {
162
+ title: t.title,
163
+ tags: t.tags ?? [],
164
+ ...t.category ? { category: t.category } : {},
165
+ attachments: t.attachments ?? [],
166
+ updatedAt: I()
167
+ }), L(o(e), { content: t.content })]);
168
+ }
169
+ async function f(e) {
170
+ let i = await j(s(e));
171
+ for (let e = 0; e < i.docs.length; e += 450) {
172
+ let n = z(t);
173
+ i.docs.slice(e, e + 450).forEach((e) => n.delete(e.ref)), await n.commit();
174
+ }
175
+ let c = B(n);
176
+ await O(o(e)), await K(V(c, `${r}/posts/${e}`)), await O(a(e));
177
+ }
178
+ return {
179
+ getPosts: c,
180
+ getPost: l,
181
+ createPost: u,
182
+ updatePost: d,
183
+ deletePost: f
184
+ };
185
+ }
186
+ //#endregion
187
+ //#region src/composables/useMemiBoardComments.ts
188
+ function J(e) {
189
+ let n = W(), r = E(), i = n.collectionPrefix, a = t(() => typeof e == "string" ? e : e.value), o = t(() => F(D(r, `${i}Posts`, a.value, "comments"), P("createdAt", "asc"))), s = x(o);
190
+ async function c(e) {
191
+ let t = z(r), n = k(D(r, `${i}Posts`, a.value, "comments"));
192
+ t.set(n, {
193
+ postId: a.value,
194
+ body: e.body,
195
+ authorUid: e.authorUid,
196
+ authorName: e.authorName,
197
+ authorPhoto: e.authorPhoto,
198
+ moderationStatus: "approved",
199
+ createdAt: I()
200
+ }), t.update(k(r, `${i}Posts`, a.value), { commentCount: M(1) }), await t.commit();
201
+ }
202
+ async function l(e) {
203
+ let t = z(r);
204
+ t.delete(k(r, `${i}Posts`, a.value, "comments", e)), t.update(k(r, `${i}Posts`, a.value), { commentCount: M(-1) }), await t.commit();
205
+ }
206
+ return {
207
+ comments: s,
208
+ addComment: c,
209
+ deleteComment: l
210
+ };
211
+ }
212
+ //#endregion
213
+ //#region src/utils/moderation-prompt.ts
214
+ var ye = "당신은 게시판 콘텐츠를 심사하는 엄격한 모더레이터입니다.\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은 \"\"", be = [
215
+ "씨발",
216
+ "시발",
217
+ "씨팔",
218
+ "시팔",
219
+ "병신",
220
+ "븅신",
221
+ "지랄",
222
+ "좆",
223
+ "존나",
224
+ "개새끼",
225
+ "개새",
226
+ "꺼져",
227
+ "닥쳐",
228
+ "씹",
229
+ "니미",
230
+ "느금",
231
+ "애미",
232
+ "애비",
233
+ "fuck",
234
+ "shit",
235
+ "bitch",
236
+ "asshole"
237
+ ];
238
+ function xe(e) {
239
+ return new RegExp(e.join("|"), "i");
240
+ }
241
+ var Se = /* @__PURE__ */ new Set([
242
+ "none",
243
+ "abuse",
244
+ "spam",
245
+ "adult",
246
+ "violence",
247
+ "other"
248
+ ]);
249
+ function Ce(e) {
250
+ let t = e.trim();
251
+ if (!t) return null;
252
+ let n;
253
+ try {
254
+ n = JSON.parse(t);
255
+ } catch {
256
+ let e = t.match(/\{[\s\S]*\}/);
257
+ if (!e) return null;
258
+ try {
259
+ n = JSON.parse(e[0]);
260
+ } catch {
261
+ return null;
262
+ }
263
+ }
264
+ if (typeof n != "object" || !n) return null;
265
+ let r = n;
266
+ if (typeof r.flagged != "boolean") return null;
267
+ let i = typeof r.category == "string" && Se.has(r.category) ? r.category : r.flagged ? "other" : "none", a = typeof r.reason == "string" ? r.reason : "";
268
+ return {
269
+ flagged: r.flagged,
270
+ category: i,
271
+ reason: a
272
+ };
273
+ }
274
+ //#endregion
275
+ //#region src/composables/useMemiBoardModeration.ts
276
+ var Y = {
277
+ flagged: !1,
278
+ category: "none",
279
+ reason: ""
280
+ };
281
+ function X() {
282
+ let e = W().moderation ?? {}, t = w(), n = xe([...be, ...e.localBlocklist ?? []]);
283
+ function r(e) {
284
+ return n.test(e) ? {
285
+ flagged: !0,
286
+ category: "abuse",
287
+ reason: "욕설·비속어가 포함되어 게시할 수 없습니다. 표현을 바꿔 주세요."
288
+ } : null;
289
+ }
290
+ function i() {
291
+ let n = pe(t, {
292
+ backend: new fe(),
293
+ useLimitedUseAppCheckTokens: !0
294
+ });
295
+ return me(n, {
296
+ model: e.model ?? "gemini-2.5-flash",
297
+ systemInstruction: ye,
298
+ generationConfig: {
299
+ temperature: 0,
300
+ maxOutputTokens: 256,
301
+ responseMimeType: "application/json",
302
+ responseSchema: H.object({ properties: {
303
+ flagged: H.boolean(),
304
+ category: H.string(),
305
+ reason: H.string()
306
+ } })
307
+ }
308
+ });
309
+ }
310
+ function a() {
311
+ return e.onError === "block" ? {
312
+ flagged: !0,
313
+ category: "other",
314
+ reason: "검열 서비스에 일시적으로 연결할 수 없어 제출이 보류되었습니다. 잠시 후 다시 시도해 주세요.",
315
+ error: !0
316
+ } : {
317
+ ...Y,
318
+ error: !0
319
+ };
320
+ }
321
+ async function o(t) {
322
+ let n = t.trim();
323
+ if (!n) return Y;
324
+ let o = r(n);
325
+ if (o) return o;
326
+ if (e.enabled === !1) return Y;
327
+ try {
328
+ let e = Ce((await i().generateContent(`심사할 텍스트:\n"""${n.slice(0, 4e3)}"""`)).response.text());
329
+ return e ? {
330
+ flagged: e.flagged,
331
+ category: e.category,
332
+ reason: e.reason
333
+ } : (console.warn("[memi-board] 검열 응답 파싱 실패 → onError 정책 적용"), a());
334
+ } catch (e) {
335
+ return console.warn("[memi-board] AI 검열 호출 실패 → onError 정책 적용", e), a();
336
+ }
337
+ }
338
+ async function s(t) {
339
+ if (e.enabled === !1 || !e.moderateImages) return Y;
340
+ try {
341
+ let e = await new Promise((e, n) => {
342
+ let r = new FileReader();
343
+ r.onload = () => e(r.result.split(",")[1] ?? ""), r.onerror = n, r.readAsDataURL(t);
344
+ }), n = Ce((await i().generateContent([{ inlineData: {
345
+ mimeType: t.type || "image/jpeg",
346
+ data: e
347
+ } }, "이 이미지를 심사해 주세요."])).response.text());
348
+ return n ? {
349
+ flagged: n.flagged,
350
+ category: n.category,
351
+ reason: n.reason
352
+ } : a();
353
+ } catch (e) {
354
+ return console.warn("[memi-board] 이미지 검열 호출 실패 → onError 정책 적용", e), a();
355
+ }
356
+ }
357
+ return {
358
+ checkText: o,
359
+ checkImage: s
360
+ };
361
+ }
362
+ //#endregion
363
+ //#region src/composables/useMemiBoardStorage.ts
364
+ function we() {
365
+ let e = W(), t = w(), n = e.collectionPrefix;
366
+ function r(e, r, i) {
367
+ let a = B(t), o = `${n}/posts/${r}/attachments/${Date.now()}-${ve(e.name)}`, s = V(a, o), c = de(s, e, {
368
+ contentType: e.type || "application/octet-stream",
369
+ contentDisposition: `attachment; filename*=UTF-8''${encodeURIComponent(e.name)}`
370
+ });
371
+ return {
372
+ promise: new Promise((t, n) => {
373
+ c.on("state_changed", (e) => {
374
+ i && e.totalBytes > 0 && i(e.bytesTransferred / e.totalBytes);
375
+ }, n, async () => {
376
+ let n = await le(s);
377
+ t({
378
+ name: e.name,
379
+ url: n,
380
+ path: o,
381
+ size: e.size,
382
+ type: e.type
383
+ });
384
+ });
385
+ }),
386
+ cancel: () => c.cancel()
387
+ };
388
+ }
389
+ async function i(e) {
390
+ let n = B(t);
391
+ await ce(V(n, e.path)).catch(() => {});
392
+ }
393
+ return {
394
+ uploadAttachment: r,
395
+ deleteAttachment: i
396
+ };
397
+ }
398
+ //#endregion
399
+ //#region src/composables/useMemiBoardSettings.ts
400
+ var Te = [
401
+ {
402
+ id: "free",
403
+ label: "자유"
404
+ },
405
+ {
406
+ id: "notice",
407
+ label: "공지"
408
+ },
409
+ {
410
+ id: "question",
411
+ label: "질문"
412
+ }
413
+ ];
414
+ function Z() {
415
+ let e = W(), n = E(), { isAdmin: r } = G(), i = e.collectionPrefix, a = k(n, `${i}Settings`, "config"), { data: o, pending: s } = C(a), c = t(() => {
416
+ let e = o.value?.categories;
417
+ return e && e.length > 0 ? e : Te;
418
+ });
419
+ function l(e) {
420
+ if (e) return c.value.find((t) => t.id === e)?.label ?? e;
421
+ }
422
+ return v([r, s], async ([e, t]) => {
423
+ if (!e || t || o.value) return;
424
+ let n = await A(a).catch(() => null);
425
+ !n || n.exists() || await L(a, {
426
+ categories: Te,
427
+ updatedAt: I()
428
+ }).catch(() => {});
429
+ }, { immediate: !0 }), {
430
+ categories: c,
431
+ categoryLabel: l,
432
+ settingsPending: s
433
+ };
434
+ }
435
+ //#endregion
436
+ //#region src/utils/formatDate.ts
437
+ function Q(e) {
438
+ return e?.toDate ? e.toDate().toLocaleDateString("ko-KR") : "";
439
+ }
440
+ //#endregion
441
+ //#region src/components/List.vue?vue&type=script&setup=true&lang.ts
442
+ var Ee = { class: "flex flex-col gap-2" }, De = {
443
+ key: 1,
444
+ class: "text-sm text-muted text-center py-8"
445
+ }, Oe = { class: "flex items-center justify-between gap-4" }, ke = { class: "flex items-center gap-2 min-w-0" }, Ae = { class: "font-medium truncate" }, je = { class: "text-xs text-muted shrink-0" }, Me = { class: "flex items-center gap-3 text-xs text-muted mt-2" }, Ne = { class: "flex items-center gap-1" }, Pe = {
446
+ key: 0,
447
+ class: "flex items-center gap-1"
448
+ }, Fe = /* @__PURE__ */ c({
449
+ __name: "List",
450
+ props: {
451
+ pageSize: { default: 20 },
452
+ linkBase: {}
453
+ },
454
+ emits: ["select"],
455
+ setup(t, { emit: c }) {
456
+ let d = t, v = c, { getPosts: b } = q(), { categoryLabel: x } = Z(), S = f([]), C = f(void 0), w = f(!1), T = f(!1), E = f(!0);
457
+ async function D() {
458
+ T.value = !0;
459
+ try {
460
+ let e = await b({
461
+ pageSize: d.pageSize,
462
+ cursor: C.value
463
+ });
464
+ S.value.push(...e.posts), C.value = e.cursor, w.value = e.hasMore;
465
+ } finally {
466
+ T.value = !1, E.value = !1;
467
+ }
468
+ }
469
+ l(D);
470
+ function O(e) {
471
+ d.linkBase || v("select", e);
472
+ }
473
+ return (c, l) => {
474
+ let d = m("USkeleton"), f = m("UBadge"), v = m("UIcon"), b = m("UCard"), C = m("UButton");
475
+ return u(), i("div", Ee, [
476
+ E.value ? (u(), i(e, { key: 0 }, p(3, (e) => s(d, {
477
+ key: e,
478
+ class: "h-20 w-full"
479
+ })), 64)) : S.value.length ? r("", !0) : (u(), i("p", De, " 아직 게시글이 없습니다. ")),
480
+ (u(!0), i(e, null, p(S.value, (e) => (u(), n(h(t.linkBase ? _(he) : "button"), {
481
+ key: e.id,
482
+ to: t.linkBase ? `${t.linkBase}/${e.id}` : void 0,
483
+ class: "text-left",
484
+ onClick: (t) => O(e)
485
+ }, {
486
+ default: y(() => [s(b, { class: "hover:bg-elevated/50 transition-colors" }, {
487
+ default: y(() => [a("div", Oe, [a("div", ke, [e.category ? (u(), n(f, {
488
+ key: 0,
489
+ label: _(x)(e.category),
490
+ variant: "subtle",
491
+ size: "sm"
492
+ }, null, 8, ["label"])) : r("", !0), a("h3", Ae, g(e.title), 1)]), a("span", je, g(_(Q)(e.createdAt)), 1)]), a("div", Me, [
493
+ a("span", null, g(e.authorName ?? "익명"), 1),
494
+ a("span", Ne, [s(v, {
495
+ name: "i-lucide-message-circle",
496
+ class: "size-3"
497
+ }), o(g(e.commentCount), 1)]),
498
+ e.attachments?.length ? (u(), i("span", Pe, [s(v, {
499
+ name: "i-lucide-paperclip",
500
+ class: "size-3"
501
+ }), o(g(e.attachments.length), 1)])) : r("", !0)
502
+ ])]),
503
+ _: 2
504
+ }, 1024)]),
505
+ _: 2
506
+ }, 1032, ["to", "onClick"]))), 128)),
507
+ w.value ? (u(), n(C, {
508
+ key: 2,
509
+ variant: "outline",
510
+ color: "neutral",
511
+ label: "더 보기",
512
+ block: "",
513
+ loading: T.value,
514
+ onClick: D
515
+ }, null, 8, ["loading"])) : r("", !0)
516
+ ]);
517
+ };
518
+ }
519
+ }), Ie = { class: "space-y-2" }, Le = ["src"], Re = { class: "flex-1 min-w-0" }, ze = { class: "text-sm font-medium truncate" }, Be = { class: "text-xs text-muted" }, Ve = {
520
+ key: 1,
521
+ class: "text-xs text-error"
522
+ }, He = {
523
+ key: 1,
524
+ class: "text-xs text-muted"
525
+ }, $ = /* @__PURE__ */ c({
526
+ __name: "Attachments",
527
+ props: {
528
+ modelValue: {},
529
+ postId: {},
530
+ editable: {
531
+ type: Boolean,
532
+ default: !1
533
+ },
534
+ maxFiles: { default: 5 }
535
+ },
536
+ emits: ["update:modelValue"],
537
+ setup(t, { emit: o }) {
538
+ let c = t, l = o, { uploadAttachment: d, deleteAttachment: h } = we(), _ = f(null), v = f(!1), y = f(0), b = f(null), x = f("");
539
+ function S(e) {
540
+ return e.startsWith("image/");
541
+ }
542
+ async function C(e) {
543
+ let t = e.target, n = Array.from(t.files ?? []);
544
+ if (t.value = "", !n.length) return;
545
+ let r = c.maxFiles - c.modelValue.length, i = n.slice(0, r);
546
+ v.value = !0, x.value = "";
547
+ try {
548
+ let e = [];
549
+ for (let t of i) {
550
+ y.value = 0;
551
+ let { promise: n } = d(t, c.postId, (e) => {
552
+ y.value = e;
553
+ });
554
+ e.push(await n);
555
+ }
556
+ l("update:modelValue", [...c.modelValue, ...e]);
557
+ } catch (e) {
558
+ x.value = e.message;
559
+ } finally {
560
+ v.value = !1;
561
+ }
562
+ }
563
+ async function w(e) {
564
+ let t = c.modelValue[e];
565
+ if (t) {
566
+ b.value = e;
567
+ try {
568
+ await h(t), l("update:modelValue", c.modelValue.filter((t, n) => n !== e));
569
+ } finally {
570
+ b.value = null;
571
+ }
572
+ }
573
+ }
574
+ function T(e) {
575
+ return e === "application/pdf" ? "i-lucide-file-text" : e.includes("zip") || e.includes("archive") || e.includes("compressed") ? "i-lucide-archive" : e.includes("spreadsheet") || e.includes("excel") || e.includes("csv") ? "i-lucide-table" : e.includes("presentation") || e.includes("powerpoint") ? "i-lucide-presentation" : S(e) ? "i-lucide-image" : e.includes("video") ? "i-lucide-video" : e.includes("audio") ? "i-lucide-music" : "i-lucide-file";
576
+ }
577
+ function E(e) {
578
+ return e < 1024 ? `${e} B` : e < 1048576 ? `${(e / 1024).toFixed(1)} KB` : `${(e / 1024 / 1024).toFixed(1)} MB`;
579
+ }
580
+ return (o, c) => {
581
+ let l = m("UIcon"), d = m("UButton"), f = m("UProgress");
582
+ return u(), i("div", Ie, [(u(!0), i(e, null, p(t.modelValue, (e, r) => (u(), i("div", {
583
+ key: e.path,
584
+ class: "flex items-center gap-3 px-3 py-2 rounded-lg border border-default"
585
+ }, [
586
+ S(e.type) ? (u(), i("img", {
587
+ key: 0,
588
+ src: e.url,
589
+ class: "size-10 rounded object-cover shrink-0"
590
+ }, null, 8, Le)) : (u(), n(l, {
591
+ key: 1,
592
+ name: T(e.type),
593
+ class: "size-5 shrink-0 text-muted"
594
+ }, null, 8, ["name"])),
595
+ a("div", Re, [a("p", ze, g(e.name), 1), a("p", Be, g(E(e.size)), 1)]),
596
+ t.editable ? (u(), n(d, {
597
+ key: 2,
598
+ icon: "i-lucide-x",
599
+ size: "xs",
600
+ color: "error",
601
+ variant: "ghost",
602
+ loading: b.value === r,
603
+ onClick: (e) => w(r)
604
+ }, null, 8, ["loading", "onClick"])) : (u(), n(d, {
605
+ key: 3,
606
+ icon: "i-lucide-download",
607
+ size: "xs",
608
+ color: "neutral",
609
+ variant: "ghost",
610
+ to: e.url,
611
+ target: "_blank"
612
+ }, null, 8, ["to"]))
613
+ ]))), 128)), t.editable && t.modelValue.length < t.maxFiles ? (u(), i(e, { key: 0 }, [
614
+ a("input", {
615
+ ref_key: "fileInput",
616
+ ref: _,
617
+ type: "file",
618
+ multiple: "",
619
+ class: "hidden",
620
+ onChange: C
621
+ }, null, 544),
622
+ s(d, {
623
+ icon: "i-lucide-paperclip",
624
+ label: `파일 첨부 (${t.modelValue.length} / ${t.maxFiles})`,
625
+ variant: "outline",
626
+ color: "neutral",
627
+ size: "sm",
628
+ loading: v.value,
629
+ onClick: c[0] ||= (e) => _.value?.click()
630
+ }, null, 8, ["label", "loading"]),
631
+ v.value ? (u(), n(f, {
632
+ key: 0,
633
+ "model-value": Math.round(y.value * 100)
634
+ }, null, 8, ["model-value"])) : r("", !0),
635
+ x.value ? (u(), i("p", Ve, g(x.value), 1)) : r("", !0)
636
+ ], 64)) : t.editable ? (u(), i("p", He, " 최대 " + g(t.maxFiles) + "개까지 첨부할 수 있습니다. ", 1)) : r("", !0)]);
637
+ };
638
+ }
639
+ }), Ue = { class: "flex justify-between items-center" }, We = {
640
+ key: 0,
641
+ class: "text-xs text-error"
642
+ }, Ge = {
643
+ key: 1,
644
+ class: "text-sm text-muted"
645
+ }, Ke = /* @__PURE__ */ c({
646
+ __name: "CommentForm",
647
+ props: { postId: {} },
648
+ setup(e) {
649
+ let t = e, { user: n, isSignedIn: o } = G(), { addComment: c } = J(t.postId), { checkText: l } = X(), d = f(""), p = f(!1), h = f("");
650
+ async function v() {
651
+ if (h.value = "", d.value.trim()) {
652
+ if (!n.value) {
653
+ h.value = "로그인이 필요합니다.";
654
+ return;
655
+ }
656
+ p.value = !0;
657
+ try {
658
+ let e = await l(d.value);
659
+ if (e.flagged) {
660
+ h.value = e.reason || "작성할 수 없는 내용이 포함되어 있습니다.";
661
+ return;
662
+ }
663
+ await c({
664
+ body: d.value,
665
+ authorUid: n.value.uid,
666
+ authorName: n.value.displayName,
667
+ authorPhoto: n.value.photoURL
668
+ }), d.value = "";
669
+ } catch (e) {
670
+ h.value = e.message;
671
+ } finally {
672
+ p.value = !1;
673
+ }
674
+ }
675
+ }
676
+ return (e, t) => {
677
+ let n = m("UTextarea"), c = m("UButton");
678
+ return _(o) ? (u(), i("form", {
679
+ key: 0,
680
+ class: "flex flex-col gap-2",
681
+ onSubmit: b(v, ["prevent"])
682
+ }, [s(n, {
683
+ modelValue: d.value,
684
+ "onUpdate:modelValue": t[0] ||= (e) => d.value = e,
685
+ placeholder: "댓글을 입력하세요",
686
+ rows: 2
687
+ }, null, 8, ["modelValue"]), a("div", Ue, [
688
+ h.value ? (u(), i("p", We, g(h.value), 1)) : r("", !0),
689
+ t[1] ||= a("div", { class: "flex-1" }, null, -1),
690
+ s(c, {
691
+ type: "submit",
692
+ size: "sm",
693
+ label: "댓글 작성",
694
+ loading: p.value,
695
+ disabled: !d.value.trim()
696
+ }, null, 8, ["loading", "disabled"])
697
+ ])], 32)) : (u(), i("p", Ge, " 댓글을 작성하려면 로그인이 필요합니다. "));
698
+ };
699
+ }
700
+ }), qe = { class: "flex flex-col gap-3" }, Je = {
701
+ key: 0,
702
+ class: "text-sm text-muted"
703
+ }, Ye = { class: "flex-1 min-w-0" }, Xe = { class: "flex items-center gap-2" }, Ze = { class: "text-sm font-medium" }, Qe = { class: "text-xs text-muted" }, $e = { class: "text-sm whitespace-pre-wrap break-words" }, et = /* @__PURE__ */ c({
704
+ __name: "CommentList",
705
+ props: { postId: {} },
706
+ setup(t) {
707
+ let { comments: o, deleteComment: c } = J(t.postId), { canDeleteComment: l } = G(), d = f(null);
708
+ async function h(e) {
709
+ d.value = e;
710
+ try {
711
+ await c(e);
712
+ } finally {
713
+ d.value = null;
714
+ }
715
+ }
716
+ return (t, c) => {
717
+ let f = m("UAvatar"), v = m("UButton");
718
+ return u(), i("div", qe, [_(o).length ? r("", !0) : (u(), i("p", Je, " 아직 댓글이 없습니다. ")), (u(!0), i(e, null, p(_(o), (e) => (u(), i("div", {
719
+ key: e.id,
720
+ class: "flex items-start gap-3"
721
+ }, [
722
+ s(f, {
723
+ src: e.authorPhoto ?? void 0,
724
+ alt: e.authorName ?? "익명",
725
+ size: "sm"
726
+ }, null, 8, ["src", "alt"]),
727
+ a("div", Ye, [a("div", Xe, [a("span", Ze, g(e.authorName ?? "익명"), 1), a("span", Qe, g(_(Q)(e.createdAt)), 1)]), a("p", $e, g(e.body), 1)]),
728
+ _(l)(e) ? (u(), n(v, {
729
+ key: 0,
730
+ icon: "i-lucide-trash-2",
731
+ size: "xs",
732
+ variant: "ghost",
733
+ color: "error",
734
+ loading: d.value === e.id,
735
+ onClick: (t) => h(e.id)
736
+ }, null, 8, ["loading", "onClick"])) : r("", !0)
737
+ ]))), 128))]);
738
+ };
739
+ }
740
+ }), tt = {
741
+ key: 0,
742
+ class: "flex flex-col gap-3"
743
+ }, nt = {
744
+ key: 1,
745
+ class: "text-sm text-muted"
746
+ }, rt = {
747
+ key: 2,
748
+ class: "flex flex-col gap-6"
749
+ }, it = { class: "flex flex-col gap-2" }, at = { class: "flex items-start justify-between gap-4" }, ot = { class: "flex items-center gap-2 min-w-0" }, st = { class: "text-2xl font-semibold" }, ct = {
750
+ key: 0,
751
+ class: "flex gap-2 shrink-0"
752
+ }, lt = { class: "flex items-center gap-3 text-sm text-muted" }, ut = {
753
+ key: 0,
754
+ class: "flex gap-1"
755
+ }, dt = { class: "whitespace-pre-wrap break-words leading-relaxed" }, ft = { class: "flex flex-col gap-4 border-t border-default pt-4" }, pt = { class: "text-sm font-medium text-muted" }, mt = /* @__PURE__ */ c({
756
+ __name: "Detail",
757
+ props: { postId: {} },
758
+ emits: ["deleted", "edit"],
759
+ setup(t, { emit: c }) {
760
+ let d = t, h = c, { getPost: b, deletePost: x } = q(), { canEdit: S, canDelete: C } = G(), { categoryLabel: w } = Z(), { comments: T } = J(d.postId), E = f(null), D = f(!0), O = f(!1), k = f(!1);
761
+ async function A() {
762
+ D.value = !0, k.value = !1;
763
+ let e = await b(d.postId);
764
+ e || (k.value = !0), E.value = e, D.value = !1;
765
+ }
766
+ l(A), v(() => d.postId, A);
767
+ async function j() {
768
+ if (E.value && window.confirm("이 게시글을 삭제하시겠습니까? 되돌릴 수 없습니다.")) {
769
+ O.value = !0;
770
+ try {
771
+ await x(d.postId), h("deleted");
772
+ } finally {
773
+ O.value = !1;
774
+ }
775
+ }
776
+ }
777
+ return (c, l) => {
778
+ let d = m("USkeleton"), f = m("UBadge"), v = m("UButton"), b = m("UAvatar");
779
+ return D.value ? (u(), i("div", tt, [s(d, { class: "h-8 w-2/3" }), s(d, { class: "h-32 w-full" })])) : k.value ? (u(), i("p", nt, " 게시글을 찾을 수 없습니다. ")) : E.value ? (u(), i("div", rt, [
780
+ a("header", it, [
781
+ a("div", at, [a("div", ot, [E.value.category ? (u(), n(f, {
782
+ key: 0,
783
+ label: _(w)(E.value.category),
784
+ variant: "subtle"
785
+ }, null, 8, ["label"])) : r("", !0), a("h1", st, g(E.value.title), 1)]), _(S)(E.value) || _(C)(E.value) ? (u(), i("div", ct, [_(S)(E.value) ? (u(), n(v, {
786
+ key: 0,
787
+ icon: "i-lucide-pencil",
788
+ size: "sm",
789
+ variant: "outline",
790
+ color: "neutral",
791
+ label: "수정",
792
+ onClick: l[0] ||= (e) => h("edit", t.postId)
793
+ })) : r("", !0), _(C)(E.value) ? (u(), n(v, {
794
+ key: 1,
795
+ icon: "i-lucide-trash-2",
796
+ size: "sm",
797
+ variant: "outline",
798
+ color: "error",
799
+ label: "삭제",
800
+ loading: O.value,
801
+ onClick: j
802
+ }, null, 8, ["loading"])) : r("", !0)])) : r("", !0)]),
803
+ a("div", lt, [
804
+ s(b, {
805
+ src: E.value.authorPhoto ?? void 0,
806
+ alt: E.value.authorName ?? "익명",
807
+ size: "xs"
808
+ }, null, 8, ["src", "alt"]),
809
+ a("span", null, g(E.value.authorName ?? "익명"), 1),
810
+ a("span", null, g(_(Q)(E.value.createdAt)), 1)
811
+ ]),
812
+ E.value.tags?.length ? (u(), i("div", ut, [(u(!0), i(e, null, p(E.value.tags, (e) => (u(), n(f, {
813
+ key: e,
814
+ variant: "subtle",
815
+ color: "neutral",
816
+ size: "sm"
817
+ }, {
818
+ default: y(() => [o(g(e), 1)]),
819
+ _: 2
820
+ }, 1024))), 128))])) : r("", !0)
821
+ ]),
822
+ a("p", dt, g(E.value.content), 1),
823
+ E.value.attachments?.length ? (u(), n($, {
824
+ key: 0,
825
+ "model-value": E.value.attachments,
826
+ "post-id": t.postId
827
+ }, null, 8, ["model-value", "post-id"])) : r("", !0),
828
+ a("section", ft, [
829
+ a("h2", pt, " 댓글 " + g(_(T).length), 1),
830
+ s(Ke, { "post-id": t.postId }, null, 8, ["post-id"]),
831
+ s(et, { "post-id": t.postId }, null, 8, ["post-id"])
832
+ ])
833
+ ])) : r("", !0);
834
+ };
835
+ }
836
+ }), ht = {
837
+ key: 0,
838
+ class: "flex flex-col gap-3"
839
+ }, gt = {
840
+ key: 0,
841
+ class: "text-sm text-error"
842
+ }, _t = { class: "flex gap-2" }, vt = /* @__PURE__ */ c({
843
+ __name: "Editor",
844
+ props: { postId: {} },
845
+ emits: ["saved", "cancel"],
846
+ setup(e, { emit: n }) {
847
+ let o = e, c = n, { user: d } = G(), { getPost: p, createPost: h, updatePost: _ } = q(), { checkText: v } = X(), { categories: y } = Z(), x = f(""), S = f(""), C = f(""), w = f(void 0), T = f([]), E = t(() => y.value.map((e) => ({
848
+ label: e.label,
849
+ value: e.id
850
+ }))), D = f(!!o.postId), O = f(!1), k = f(""), A = f(o.postId ?? `new-${Date.now()}`);
851
+ l(async () => {
852
+ if (o.postId) try {
853
+ let e = await p(o.postId);
854
+ if (!e) {
855
+ k.value = "게시글을 찾을 수 없습니다.";
856
+ return;
857
+ }
858
+ x.value = e.title, S.value = e.content, C.value = (e.tags ?? []).join(", "), w.value = e.category, T.value = e.attachments ?? [];
859
+ } finally {
860
+ D.value = !1;
861
+ }
862
+ });
863
+ async function j() {
864
+ if (k.value = "", !x.value.trim() || !S.value.trim()) {
865
+ k.value = "제목과 내용을 입력해 주세요.";
866
+ return;
867
+ }
868
+ if (!d.value) {
869
+ k.value = "로그인이 필요합니다.";
870
+ return;
871
+ }
872
+ O.value = !0;
873
+ try {
874
+ let e = await v(`${x.value}\n${S.value}`);
875
+ if (e.flagged) {
876
+ k.value = e.reason || "게시할 수 없는 내용이 포함되어 있습니다.";
877
+ return;
878
+ }
879
+ let t = C.value.split(",").map((e) => e.trim()).filter(Boolean);
880
+ if (o.postId) await _(o.postId, {
881
+ title: x.value,
882
+ content: S.value,
883
+ tags: t,
884
+ category: w.value,
885
+ attachments: T.value
886
+ }), c("saved", o.postId);
887
+ else {
888
+ let e = await h({
889
+ title: x.value,
890
+ content: S.value,
891
+ tags: t,
892
+ category: w.value,
893
+ attachments: T.value,
894
+ authorUid: d.value.uid,
895
+ authorName: d.value.displayName,
896
+ authorPhoto: d.value.photoURL
897
+ });
898
+ c("saved", e);
899
+ }
900
+ } catch (e) {
901
+ k.value = e.message;
902
+ } finally {
903
+ O.value = !1;
904
+ }
905
+ }
906
+ return (t, n) => {
907
+ let o = m("USkeleton"), l = m("USelect"), d = m("UInput"), f = m("UTextarea"), p = m("UButton");
908
+ return D.value ? (u(), i("div", ht, [s(o, { class: "h-10 w-full" }), s(o, { class: "h-40 w-full" })])) : (u(), i("form", {
909
+ key: 1,
910
+ class: "flex flex-col gap-4",
911
+ onSubmit: b(j, ["prevent"])
912
+ }, [
913
+ s(l, {
914
+ modelValue: w.value,
915
+ "onUpdate:modelValue": n[0] ||= (e) => w.value = e,
916
+ items: E.value,
917
+ placeholder: "카테고리 선택",
918
+ class: "w-40"
919
+ }, null, 8, ["modelValue", "items"]),
920
+ s(d, {
921
+ modelValue: x.value,
922
+ "onUpdate:modelValue": n[1] ||= (e) => x.value = e,
923
+ placeholder: "제목",
924
+ size: "lg",
925
+ required: ""
926
+ }, null, 8, ["modelValue"]),
927
+ s(f, {
928
+ modelValue: S.value,
929
+ "onUpdate:modelValue": n[2] ||= (e) => S.value = e,
930
+ placeholder: "내용을 입력하세요",
931
+ rows: 12,
932
+ required: ""
933
+ }, null, 8, ["modelValue"]),
934
+ s(d, {
935
+ modelValue: C.value,
936
+ "onUpdate:modelValue": n[3] ||= (e) => C.value = e,
937
+ placeholder: "태그 (쉼표로 구분)"
938
+ }, null, 8, ["modelValue"]),
939
+ s($, {
940
+ modelValue: T.value,
941
+ "onUpdate:modelValue": n[4] ||= (e) => T.value = e,
942
+ "post-id": A.value,
943
+ editable: ""
944
+ }, null, 8, ["modelValue", "post-id"]),
945
+ k.value ? (u(), i("p", gt, g(k.value), 1)) : r("", !0),
946
+ a("div", _t, [s(p, {
947
+ type: "submit",
948
+ loading: O.value,
949
+ label: e.postId ? "수정 완료" : "게시하기"
950
+ }, null, 8, ["loading", "label"]), s(p, {
951
+ variant: "ghost",
952
+ color: "neutral",
953
+ label: "취소",
954
+ onClick: n[5] ||= (e) => c("cancel")
955
+ })])
956
+ ], 32));
957
+ };
958
+ }
959
+ }), yt = { class: "flex flex-col gap-4 max-w-sm" }, bt = {
960
+ key: 0,
961
+ class: "flex items-center gap-2 text-xs text-muted"
962
+ }, xt = {
963
+ key: 3,
964
+ class: "text-sm text-error"
965
+ }, St = /* @__PURE__ */ c({
966
+ __name: "SignIn",
967
+ setup(c) {
968
+ let l = W(), d = t(() => l.auth?.providers ?? ["google", "apple"]), p = t(() => d.value.includes("google") || d.value.includes("apple")), { signInWithGoogle: h, signInWithApple: _, signInWithEmail: v, signUpWithEmail: y } = G(), x = f("signin"), S = f(""), C = f(""), w = f(""), T = f(!1), E = f("");
969
+ async function D() {
970
+ T.value = !0, E.value = "";
971
+ try {
972
+ await h();
973
+ } catch (e) {
974
+ E.value = e.message;
975
+ } finally {
976
+ T.value = !1;
977
+ }
978
+ }
979
+ async function O() {
980
+ T.value = !0, E.value = "";
981
+ try {
982
+ await _();
983
+ } catch (e) {
984
+ E.value = e.message;
985
+ } finally {
986
+ T.value = !1;
987
+ }
988
+ }
989
+ async function k() {
990
+ T.value = !0, E.value = "";
991
+ try {
992
+ x.value === "signin" ? await v(S.value, C.value) : await y(S.value, C.value, w.value || void 0);
993
+ } catch (e) {
994
+ E.value = e.message;
995
+ } finally {
996
+ T.value = !1;
997
+ }
998
+ }
999
+ return (t, c) => {
1000
+ let l = m("UButton"), f = m("UInput");
1001
+ return u(), i("div", yt, [
1002
+ d.value.includes("google") ? (u(), n(l, {
1003
+ key: 0,
1004
+ icon: "i-simple-icons-google",
1005
+ color: "neutral",
1006
+ variant: "outline",
1007
+ label: "Google로 로그인",
1008
+ block: "",
1009
+ loading: T.value,
1010
+ onClick: D
1011
+ }, null, 8, ["loading"])) : r("", !0),
1012
+ d.value.includes("apple") ? (u(), n(l, {
1013
+ key: 1,
1014
+ icon: "i-simple-icons-apple",
1015
+ color: "neutral",
1016
+ variant: "outline",
1017
+ label: "Apple로 로그인",
1018
+ block: "",
1019
+ loading: T.value,
1020
+ onClick: O
1021
+ }, null, 8, ["loading"])) : r("", !0),
1022
+ d.value.includes("emailPassword") ? (u(), i(e, { key: 2 }, [
1023
+ p.value ? (u(), i("div", bt, [...c[4] ||= [
1024
+ a("div", { class: "flex-1 border-t border-default" }, null, -1),
1025
+ o(" 또는 ", -1),
1026
+ a("div", { class: "flex-1 border-t border-default" }, null, -1)
1027
+ ]])) : r("", !0),
1028
+ a("form", {
1029
+ class: "flex flex-col gap-3",
1030
+ onSubmit: b(k, ["prevent"])
1031
+ }, [
1032
+ x.value === "signup" ? (u(), n(f, {
1033
+ key: 0,
1034
+ modelValue: w.value,
1035
+ "onUpdate:modelValue": c[0] ||= (e) => w.value = e,
1036
+ placeholder: "이름"
1037
+ }, null, 8, ["modelValue"])) : r("", !0),
1038
+ s(f, {
1039
+ modelValue: S.value,
1040
+ "onUpdate:modelValue": c[1] ||= (e) => S.value = e,
1041
+ type: "email",
1042
+ placeholder: "이메일",
1043
+ required: ""
1044
+ }, null, 8, ["modelValue"]),
1045
+ s(f, {
1046
+ modelValue: C.value,
1047
+ "onUpdate:modelValue": c[2] ||= (e) => C.value = e,
1048
+ type: "password",
1049
+ placeholder: "비밀번호",
1050
+ required: ""
1051
+ }, null, 8, ["modelValue"]),
1052
+ s(l, {
1053
+ type: "submit",
1054
+ block: "",
1055
+ loading: T.value,
1056
+ label: x.value === "signin" ? "로그인" : "회원가입"
1057
+ }, null, 8, ["loading", "label"])
1058
+ ], 32),
1059
+ s(l, {
1060
+ variant: "link",
1061
+ size: "sm",
1062
+ color: "neutral",
1063
+ label: x.value === "signin" ? "계정이 없으신가요? 회원가입" : "이미 계정이 있으신가요? 로그인",
1064
+ onClick: c[3] ||= (e) => x.value = x.value === "signin" ? "signup" : "signin"
1065
+ }, null, 8, ["label"])
1066
+ ], 64)) : r("", !0),
1067
+ E.value ? (u(), i("p", xt, g(E.value), 1)) : r("", !0)
1068
+ ]);
1069
+ };
1070
+ }
1071
+ }), Ct = [
1072
+ {
1073
+ version: "0.2.2",
1074
+ date: "2026-07-31",
1075
+ highlights: ["카테고리 — 글을 주제별로 분류해서 올릴 수 있어요"]
1076
+ },
1077
+ {
1078
+ version: "0.2.0",
1079
+ date: "2026-07-31",
1080
+ highlights: [
1081
+ "글쓰기 / 수정 / 삭제 — 제목, 본문, 태그를 자유롭게 작성",
1082
+ "파일 첨부 — 이미지·문서를 게시글에 첨부, 이미지는 미리보기 제공",
1083
+ "댓글 — 실시간으로 댓글을 달고 지울 수 있음",
1084
+ "로그인 / 권한 — 로그인해야 글을 쓸 수 있고, 본인 글만 수정·삭제 가능(관리자는 전체 관리)",
1085
+ "AI 자동 검열 — 욕설·부적절한 내용이 담긴 글/댓글은 AI가 자동으로 걸러 게시를 막음",
1086
+ "한 줄 설정으로 바로 시작 — pages: true 옵션 하나로 게시판 화면이 바로 만들어짐"
1087
+ ],
1088
+ note: "서버 없이 동작하는 모듈이라, 실제 보안은 Firebase 프로젝트에 규칙 파일을 등록해야 완성됩니다. README의 Security Model 안내를 꼭 따라주세요."
1089
+ },
1090
+ {
1091
+ version: "0.1.0",
1092
+ date: "2026-07-27",
1093
+ highlights: ["프로젝트 최초 생성 (아직 게시판 기능은 없는 빈 Nuxt 4 프로젝트)"]
1094
+ }
1095
+ ], wt = { class: "flex flex-col gap-6" }, Tt = { class: "flex items-center gap-2" }, Et = { class: "text-sm text-muted" }, Dt = { class: "list-disc list-inside text-sm leading-relaxed space-y-1" }, Ot = {
1096
+ key: 0,
1097
+ class: "text-xs text-muted"
1098
+ }, kt = /* @__PURE__ */ c({
1099
+ __name: "VersionHistory",
1100
+ setup(t) {
1101
+ return (t, n) => {
1102
+ let o = m("UBadge");
1103
+ return u(), i("div", wt, [(u(!0), i(e, null, p(_(Ct), (t) => (u(), i("div", {
1104
+ key: t.version,
1105
+ class: "flex flex-col gap-2"
1106
+ }, [
1107
+ a("div", Tt, [s(o, {
1108
+ label: `v${t.version}`,
1109
+ color: "primary",
1110
+ variant: "subtle"
1111
+ }, null, 8, ["label"]), a("span", Et, g(t.date), 1)]),
1112
+ a("ul", Dt, [(u(!0), i(e, null, p(t.highlights, (e) => (u(), i("li", { key: e }, g(e), 1))), 128))]),
1113
+ t.note ? (u(), i("p", Ot, g(t.note), 1)) : r("", !0)
1114
+ ]))), 128))]);
1115
+ };
1116
+ }
1117
+ });
1118
+ //#endregion
1119
+ export { $ as MemiBoardAttachments, Ke as MemiBoardCommentForm, et as MemiBoardCommentList, mt as MemiBoardDetail, vt as MemiBoardEditor, Fe as MemiBoardList, St as MemiBoardSignIn, kt as MemiBoardVersionHistory, ge as configureMemiBoard, G as useMemiBoardAuth, J as useMemiBoardComments, W as useMemiBoardConfig, X as useMemiBoardModeration, q as useMemiBoardPosts, Z as useMemiBoardSettings, we as useMemiBoardStorage, Ct as versionHistory };
1120
+
1121
+ //# sourceMappingURL=index.js.map