memi-board 0.22.0 → 0.28.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.
Files changed (48) hide show
  1. package/README.md +26 -40
  2. package/dist/composables/useMemiBoardAuthorMemo.d.ts +19 -0
  3. package/dist/composables/useMemiBoardComments.d.ts +6 -1
  4. package/dist/composables/useMemiBoardSettings.d.ts +2 -2
  5. package/dist/composables/useMemiBoardStorage.d.ts +3 -2
  6. package/dist/composables/useMemiBoardUserComments.d.ts +19 -0
  7. package/dist/composables/useMemiBoardUserPosts.d.ts +4 -2
  8. package/dist/composables/useMemiBoardUserProfile.d.ts +10 -0
  9. package/dist/composables/useMemiBoardUsers.d.ts +5 -4
  10. package/dist/composables/useMemiBoardViews.d.ts +1 -1
  11. package/dist/config.d.ts +8 -2
  12. package/dist/{heic2any-BXb_U1zK.js → heic2any-BHVEj2CD.js} +2 -2
  13. package/dist/{heic2any-BXb_U1zK.js.map → heic2any-BHVEj2CD.js.map} +1 -1
  14. package/dist/index.d.ts +5 -5
  15. package/dist/index.js +870 -906
  16. package/dist/index.js.map +1 -1
  17. package/dist/module.mjs +35 -29
  18. package/dist/module.mjs.map +1 -1
  19. package/dist/runtime/components/Attachments.vue +2 -3
  20. package/dist/runtime/components/AuthorMenu.vue +129 -21
  21. package/dist/runtime/components/AvatarCropModal.vue +108 -0
  22. package/dist/runtime/components/CategorySettings.vue +1 -1
  23. package/dist/runtime/components/CommentItem.vue +8 -7
  24. package/dist/runtime/components/Detail.vue +14 -7
  25. package/dist/runtime/components/Editor.vue +8 -4
  26. package/dist/runtime/components/List.vue +6 -0
  27. package/dist/runtime/components/ListDefault.vue +32 -29
  28. package/dist/runtime/components/ListDense.vue +86 -97
  29. package/dist/runtime/components/ListVideo.vue +13 -8
  30. package/dist/runtime/components/Profile.vue +93 -0
  31. package/dist/runtime/components/ProfileEditModal.vue +190 -0
  32. package/dist/runtime/components/Settings.vue +1 -1
  33. package/dist/runtime/components/UserCommentList.vue +109 -0
  34. package/dist/runtime/components/UserPostList.vue +10 -1
  35. package/dist/runtime/components/Users.vue +1 -1
  36. package/dist/slugify-CDMIDDCN.js +122 -0
  37. package/dist/slugify-CDMIDDCN.js.map +1 -0
  38. package/dist/storage.d.ts +9 -0
  39. package/dist/storage.js +148 -0
  40. package/dist/storage.js.map +1 -0
  41. package/dist/types.d.ts +41 -10
  42. package/dist/utils/boardPaths.d.ts +30 -15
  43. package/docs/firebase-setup.md +3 -4
  44. package/docs/firestore.indexes.json.example +136 -0
  45. package/docs/firestore.rules.example +128 -72
  46. package/docs/storage.rules.example +22 -12
  47. package/package.json +7 -2
  48. package/dist/rolldown-runtime-DtPi1Y-2.js +0 -13
@@ -0,0 +1,109 @@
1
+ <script setup lang="ts">
2
+ import { ref, resolveComponent } from 'vue'
3
+ import { formatRelativeDate, useMemiBoardUserComments } from 'memi-board/runtime'
4
+ import type { CommentModel } from 'memi-board/runtime'
5
+
6
+ const NuxtLink = resolveComponent('NuxtLink')
7
+
8
+ const props = withDefaults(defineProps<{
9
+ /** 댓글 목록을 모을 작성자 uid */
10
+ uid: string
11
+ pageSize?: number
12
+ /** 댓글이 달린 글로 이동하는 링크 생성 — comment.boardId/postId로 만든다. */
13
+ getPostLink?: (comment: CommentModel) => string | undefined
14
+ /** 프로필의 미리보기처럼 더 보기 버튼 없이 pageSize만큼만 딱 보여줄 때 true. */
15
+ hideLoadMore?: boolean
16
+ }>(), {
17
+ pageSize: 10,
18
+ hideLoadMore: false,
19
+ })
20
+
21
+ const emit = defineEmits<{ select: [comment: CommentModel] }>()
22
+
23
+ const { comments, commentsPending, hasMore, loadingMore, loadError, loadMore } = useMemiBoardUserComments(
24
+ () => props.uid,
25
+ { pageSize: props.pageSize },
26
+ )
27
+
28
+ const now = ref(Date.now())
29
+
30
+ function commentLink(comment: CommentModel): string | undefined {
31
+ return props.getPostLink?.(comment)
32
+ }
33
+ </script>
34
+
35
+ <template>
36
+ <div class="flex flex-col gap-2">
37
+ <template v-if="commentsPending">
38
+ <USkeleton
39
+ v-for="i in 3"
40
+ :key="i"
41
+ class="h-16 w-full"
42
+ />
43
+ </template>
44
+
45
+ <p
46
+ v-else-if="loadError"
47
+ class="text-sm text-error text-center py-8"
48
+ >
49
+ 목록을 불러오지 못했습니다: {{ loadError }}
50
+ </p>
51
+
52
+ <p
53
+ v-else-if="!comments.length"
54
+ class="text-sm text-muted text-center py-8"
55
+ >
56
+ 작성한 댓글이 없습니다.
57
+ </p>
58
+
59
+ <ul
60
+ v-else
61
+ class="flex flex-col gap-2"
62
+ >
63
+ <li
64
+ v-for="comment in comments"
65
+ :key="comment.id"
66
+ >
67
+ <component
68
+ :is="commentLink(comment) ? NuxtLink : 'button'"
69
+ :to="commentLink(comment)"
70
+ class="block w-full rounded-lg border border-default p-3 text-left transition-colors"
71
+ :class="commentLink(comment) ? 'hover:bg-elevated/60' : ''"
72
+ @click="!commentLink(comment) && emit('select', comment)"
73
+ >
74
+ <p
75
+ v-if="comment.isBlinded"
76
+ class="text-sm text-muted italic"
77
+ >
78
+ 관리자에 의해 블라인드된 댓글입니다.
79
+ </p>
80
+ <p
81
+ v-else
82
+ class="text-sm text-highlighted line-clamp-3 whitespace-pre-wrap"
83
+ >
84
+ {{ comment.body }}
85
+ </p>
86
+ <p class="mt-1 text-xs text-muted">
87
+ {{ formatRelativeDate(comment.createdAt, now) }}
88
+ </p>
89
+ </component>
90
+ </li>
91
+ </ul>
92
+
93
+ <div
94
+ v-if="!commentsPending && hasMore && !hideLoadMore"
95
+ class="flex justify-center py-1"
96
+ >
97
+ <UButton
98
+ variant="outline"
99
+ color="neutral"
100
+ label="더 보기"
101
+ block
102
+ class="w-full"
103
+ :loading="loadingMore"
104
+ :disabled="loadingMore"
105
+ @click="loadMore"
106
+ />
107
+ </div>
108
+ </div>
109
+ </template>
@@ -10,10 +10,17 @@ const props = withDefaults(defineProps<{
10
10
  pageSize?: number
11
11
  /** 글 상세 링크 생성 — post.boardId로 보드를 구분해야 한다. */
12
12
  getPostLink?: (post: UserPostModel) => string | undefined
13
+ /** 카드의 작성자 메뉴 중 "프로필 보기" 링크. */
14
+ authorProfileTo?: (authorUid: string) => string | undefined
13
15
  /** 카드의 작성자 메뉴 중 "작성글 보기" 링크. */
14
16
  authorPostsTo?: (authorUid: string) => string | undefined
17
+ /** 카드의 작성자 메뉴 중 "작성한 댓글 보기" 링크. */
18
+ authorCommentsTo?: (authorUid: string) => string | undefined
19
+ /** 프로필의 미리보기처럼 더 보기 버튼 없이 pageSize만큼만 딱 보여줄 때 true. */
20
+ hideLoadMore?: boolean
15
21
  }>(), {
16
22
  pageSize: 10,
23
+ hideLoadMore: false,
17
24
  })
18
25
 
19
26
  const emit = defineEmits<{ select: [post: UserPostModel] }>()
@@ -61,11 +68,13 @@ function postTo(post: PostModel): string | undefined {
61
68
  :now="now"
62
69
  :show-category="false"
63
70
  :author-posts-to="authorPostsTo"
71
+ :author-profile-to="authorProfileTo"
72
+ :author-comments-to="authorCommentsTo"
64
73
  @select="(post: PostModel) => emit('select', post as UserPostModel)"
65
74
  />
66
75
 
67
76
  <div
68
- v-if="!postsPending && hasMore"
77
+ v-if="!postsPending && hasMore && !hideLoadMore"
69
78
  class="flex justify-center py-1"
70
79
  >
71
80
  <UButton
@@ -45,7 +45,7 @@ async function changeRole(uid: string, role: BoardUserRole) {
45
45
  <p class="truncate font-medium text-highlighted">{{ boardUser.displayName || '이름 없음' }}</p>
46
46
  <UBadge v-if="savedUid === boardUser.id" color="success" variant="subtle" label="저장됨" />
47
47
  </div>
48
- <p class="truncate text-sm text-muted">{{ boardUser.email || boardUser.id }}</p>
48
+ <p class="truncate text-sm text-muted">{{ boardUser.id }}</p>
49
49
  <p v-if="boardUser.moderationBlockCount" class="mt-1 text-xs text-warning">콘텐츠 경고 {{ boardUser.moderationBlockCount }}회</p>
50
50
  </div>
51
51
  <USelect
@@ -0,0 +1,122 @@
1
+ import { reactive as e } from "vue";
2
+ import { collection as t, doc as n } from "firebase/firestore";
3
+ //#region \0rolldown/runtime.js
4
+ var r = Object.create, i = Object.defineProperty, a = Object.getOwnPropertyDescriptor, o = Object.getOwnPropertyNames, s = Object.getPrototypeOf, c = Object.prototype.hasOwnProperty, l = (e, t) => () => (t || (e((t = { exports: {} }).exports, t), e = null), t.exports), u = (e, t, n, r) => {
5
+ if (t && typeof t == "object" || typeof t == "function") for (var s = o(t), l = 0, u = s.length, d; l < u; l++) d = s[l], !c.call(e, d) && d !== n && i(e, d, {
6
+ get: ((e) => t[e]).bind(null, d),
7
+ enumerable: !(r = a(t, d)) || r.enumerable
8
+ });
9
+ return e;
10
+ }, d = (e, t, n) => (n = e == null ? {} : r(s(e)), u(t || !e || !e.__esModule || !c.call(e, "default") ? i(n, "default", {
11
+ value: e,
12
+ enumerable: !0
13
+ }) : n, e)), f = "memiBoardPosts", p = "memiBoardComments", m = "memiBoardLikes", h = "memiBoardSettings", g = "memiBoardUsers";
14
+ function _(e) {
15
+ return {
16
+ postsCollection: e.postsCollection?.trim() || "memiBoardPosts",
17
+ commentsCollection: e.commentsCollection?.trim() || "memiBoardComments",
18
+ likesCollection: e.likesCollection?.trim() || "memiBoardLikes",
19
+ settingsCollection: e.settingsCollection?.trim() || "memiBoardSettings",
20
+ usersCollection: e.usersCollection?.trim() || "memiBoardUsers"
21
+ };
22
+ }
23
+ function v(e, n) {
24
+ return t(e, n.settingsCollection);
25
+ }
26
+ function y(e, t, r) {
27
+ return n(e, t.settingsCollection, r);
28
+ }
29
+ function b(e, n) {
30
+ return t(e, n.postsCollection);
31
+ }
32
+ function x(e, t, r) {
33
+ return n(e, t.postsCollection, r);
34
+ }
35
+ function S(e, t, r) {
36
+ return n(e, t.postsCollection, r, "body", "main");
37
+ }
38
+ function C(e, n) {
39
+ return t(e, n.commentsCollection);
40
+ }
41
+ function w(e, t, r) {
42
+ return n(e, t.commentsCollection, r);
43
+ }
44
+ function T(e, n) {
45
+ return t(e, n.likesCollection);
46
+ }
47
+ function E(e, t) {
48
+ return `${e}_${t}`;
49
+ }
50
+ function D(e, t, r, i) {
51
+ return n(e, t.likesCollection, E(r, i));
52
+ }
53
+ function O(e, n) {
54
+ return t(e, n.usersCollection);
55
+ }
56
+ function k(e, t, r) {
57
+ return n(e, t.usersCollection, r);
58
+ }
59
+ function A(e, t, r) {
60
+ return n(e, t.usersCollection, r, "private", "info");
61
+ }
62
+ function j(e, t, r, i) {
63
+ return n(e, t.usersCollection, r, "memos", i);
64
+ }
65
+ function M(e, t) {
66
+ return `${e.postsCollection}/${t}`;
67
+ }
68
+ function N(e, t) {
69
+ return `${e.postsCollection}/${e.usersCollection}/${t}`;
70
+ }
71
+ //#endregion
72
+ //#region src/config.ts
73
+ var P = "__MEMI_BOARD_CONFIG__";
74
+ function F() {
75
+ let t = globalThis;
76
+ return t[P] || (t[P] = e({
77
+ postsCollection: f,
78
+ commentsCollection: p,
79
+ likesCollection: m,
80
+ settingsCollection: h,
81
+ usersCollection: g,
82
+ auth: { providers: ["google", "apple"] },
83
+ moderation: {
84
+ enabled: !0,
85
+ model: "gemini-3.5-flash-lite",
86
+ onError: "allow",
87
+ moderateImages: !1,
88
+ localBlocklist: [],
89
+ blockBanThreshold: 3,
90
+ blockBanDecayMs: 864e5
91
+ },
92
+ seo: {
93
+ enabled: !0,
94
+ siteName: "Board",
95
+ siteUrl: "",
96
+ defaultOgImage: "",
97
+ basePath: "/board"
98
+ }
99
+ })), t[P];
100
+ }
101
+ function I(e) {
102
+ let t = F();
103
+ e.postsCollection != null && e.postsCollection !== "" && (t.postsCollection = e.postsCollection.trim()), e.commentsCollection != null && e.commentsCollection !== "" && (t.commentsCollection = e.commentsCollection.trim()), e.likesCollection != null && e.likesCollection !== "" && (t.likesCollection = e.likesCollection.trim()), e.settingsCollection != null && e.settingsCollection !== "" && (t.settingsCollection = e.settingsCollection.trim()), e.usersCollection != null && e.usersCollection !== "" && (t.usersCollection = e.usersCollection.trim()), e.auth && Object.assign(t.auth, e.auth), e.moderation && Object.assign(t.moderation, e.moderation), e.seo && Object.assign(t.seo, e.seo);
104
+ }
105
+ function L() {
106
+ return F();
107
+ }
108
+ function R() {
109
+ return _(L());
110
+ }
111
+ //#endregion
112
+ //#region src/utils/slugify.ts
113
+ function z(e) {
114
+ return e.trim().replace(/\s+/g, "-").replace(/[^a-zA-Z0-9가-힣ㄱ-ㅎㅏ-ㅣ-]/g, "").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
115
+ }
116
+ function B(e, t = 60) {
117
+ return e.replace(/[^a-zA-Z0-9가-힣ㄱ-ㅎ._-]/g, "-").slice(0, t);
118
+ }
119
+ //#endregion
120
+ export { M as C, y as D, v as E, l as O, x as S, _ as T, C as _, L as a, T as b, f as c, j as d, N as f, w as g, O as h, R as i, d as k, h as l, A as m, z as n, p as o, k as p, I as r, m as s, B as t, g as u, D as v, b as w, S as x, E as y };
121
+
122
+ //# sourceMappingURL=slugify-CDMIDDCN.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"slugify-CDMIDDCN.js","names":[],"sources":["../src/utils/boardPaths.ts","../src/config.ts","../src/utils/slugify.ts"],"sourcesContent":["/**\n * Firestore / Storage 경로\n *\n * ```\n * memiBoardUsers/{uid}\n * memiBoardSettings/{boardId} // 보드 설정 (구 memiBoards/{id} stub + settings/config 통합)\n * memiBoardPosts/{postId} // boardId 는 문서 필드\n * memiBoardPosts/{postId}/body/main\n * memiBoardComments/{commentId} // postId, boardId 는 문서 필드\n * memiBoardLikes/{postId}_{uid} // uid, postId, boardId 는 문서 필드\n * ```\n *\n * Storage: `memiBoardPosts/{postId}/...` (postId 가 전역 고유이므로 boardId 불필요)\n *\n * 카테고리 개념 없음 — 예전 category id 가 곧 boardId. boardId/postId 는 경로가\n * 아니라 문서 필드이므로, 보드/글 단위로 좁혀야 하는 쿼리는 항상 where 필터로\n * 스코핑한다 (컬렉션 자체는 항상 최상위 flat 컬렉션).\n */\nimport {\n collection,\n doc,\n type CollectionReference,\n type DocumentReference,\n type Firestore,\n} from 'firebase/firestore'\n\nexport const DEFAULT_POSTS_COLLECTION = 'memiBoardPosts'\nexport const DEFAULT_COMMENTS_COLLECTION = 'memiBoardComments'\nexport const DEFAULT_LIKES_COLLECTION = 'memiBoardLikes'\nexport const DEFAULT_SETTINGS_COLLECTION = 'memiBoardSettings'\nexport const DEFAULT_USERS_COLLECTION = 'memiBoardUsers'\n\nexport interface BoardPathConfig {\n postsCollection: string\n commentsCollection: string\n likesCollection: string\n settingsCollection: string\n usersCollection: string\n}\n\nexport function resolveBoardPathConfig(input: {\n postsCollection?: string\n commentsCollection?: string\n likesCollection?: string\n settingsCollection?: string\n usersCollection?: string\n}): BoardPathConfig {\n return {\n postsCollection: input.postsCollection?.trim() || DEFAULT_POSTS_COLLECTION,\n commentsCollection: input.commentsCollection?.trim() || DEFAULT_COMMENTS_COLLECTION,\n likesCollection: input.likesCollection?.trim() || DEFAULT_LIKES_COLLECTION,\n settingsCollection: input.settingsCollection?.trim() || DEFAULT_SETTINGS_COLLECTION,\n usersCollection: input.usersCollection?.trim() || DEFAULT_USERS_COLLECTION,\n }\n}\n\n/** 보드 설정 목록/문서 — 구 memiBoards/{boardId} stub + settings/config 통합 */\nexport function settingsCol(db: Firestore, cfg: BoardPathConfig): CollectionReference {\n return collection(db, cfg.settingsCollection)\n}\n\nexport function settingsDoc(\n db: Firestore,\n cfg: BoardPathConfig,\n boardId: string,\n): DocumentReference {\n return doc(db, cfg.settingsCollection, boardId)\n}\n\nexport function postsCol(db: Firestore, cfg: BoardPathConfig): CollectionReference {\n return collection(db, cfg.postsCollection)\n}\n\nexport function postDoc(db: Firestore, cfg: BoardPathConfig, postId: string): DocumentReference {\n return doc(db, cfg.postsCollection, postId)\n}\n\nexport function postBodyDoc(\n db: Firestore,\n cfg: BoardPathConfig,\n postId: string,\n): DocumentReference {\n return doc(db, cfg.postsCollection, postId, 'body', 'main')\n}\n\nexport function commentsCol(db: Firestore, cfg: BoardPathConfig): CollectionReference {\n return collection(db, cfg.commentsCollection)\n}\n\nexport function commentDoc(\n db: Firestore,\n cfg: BoardPathConfig,\n commentId: string,\n): DocumentReference {\n return doc(db, cfg.commentsCollection, commentId)\n}\n\nexport function likesCol(db: Firestore, cfg: BoardPathConfig): CollectionReference {\n return collection(db, cfg.likesCollection)\n}\n\n/** 좋아요 문서ID 관례: `${postId}_${uid}` — 유저당 글당 좋아요 1개, 토글은 create-or-delete */\nexport function likeDocId(postId: string, uid: string): string {\n return `${postId}_${uid}`\n}\n\nexport function likeDoc(\n db: Firestore,\n cfg: BoardPathConfig,\n postId: string,\n uid: string,\n): DocumentReference {\n return doc(db, cfg.likesCollection, likeDocId(postId, uid))\n}\n\nexport function boardUsersCol(db: Firestore, cfg: BoardPathConfig): CollectionReference {\n return collection(db, cfg.usersCollection)\n}\n\nexport function boardUserDoc(\n db: Firestore,\n cfg: BoardPathConfig,\n uid: string,\n): DocumentReference {\n return doc(db, cfg.usersCollection, uid)\n}\n\n/** 이메일만 담는 비공개 문서 — memiBoardUsers/{uid}는 공개 프로필이라 이메일만 분리한다. */\nexport function boardUserPrivateDoc(\n db: Firestore,\n cfg: BoardPathConfig,\n uid: string,\n): DocumentReference {\n return doc(db, cfg.usersCollection, uid, 'private', 'info')\n}\n\n/** 본인만 보는 작성자 메모 — 문서 ID 자체가 targetUid라 대상당 메모가 하나로 고정된다. */\nexport function authorMemoDoc(\n db: Firestore,\n cfg: BoardPathConfig,\n viewerUid: string,\n targetUid: string,\n): DocumentReference {\n return doc(db, cfg.usersCollection, viewerUid, 'memos', targetUid)\n}\n\n/** postId 가 전역 고유이므로 boardId 프리픽스 불필요 */\nexport function postStorageFolder(cfg: BoardPathConfig, postId: string): string {\n return `${cfg.postsCollection}/${postId}`\n}\n\nexport function boardSsrKey(cfg: BoardPathConfig, suffix: string): string {\n return `${cfg.postsCollection}/${cfg.usersCollection}/${suffix}`\n}\n","import { reactive } from 'vue'\nimport {\n DEFAULT_COMMENTS_COLLECTION,\n DEFAULT_LIKES_COLLECTION,\n DEFAULT_POSTS_COLLECTION,\n DEFAULT_SETTINGS_COLLECTION,\n DEFAULT_USERS_COLLECTION,\n resolveBoardPathConfig,\n type BoardPathConfig,\n} from './utils/boardPaths'\n\nexport interface MemiBoardAuthOptions {\n providers?: Array<'google' | 'apple' | 'emailPassword'>\n}\n\nexport interface MemiBoardModerationOptions {\n enabled?: boolean\n model?: string\n localBlocklist?: string[]\n onError?: 'allow' | 'block'\n moderateImages?: boolean\n useLimitedUseAppCheckTokens?: boolean\n blockBanThreshold?: number\n blockBanDecayMs?: number\n}\n\nexport interface MemiBoardSeoOptions {\n enabled?: boolean\n siteName?: string\n siteUrl?: string\n defaultOgImage?: string\n /**\n * 호스트 게시판 base path (trailing slash 없음). 기본 '/board'\n * canonical: {base}, {base}/{boardId}, {base}/{boardId}/{slug}\n */\n basePath?: string\n}\n\nexport interface MemiBoardConfig {\n /** 글 컬렉션. 기본 `memiBoardPosts` */\n postsCollection: string\n /** 댓글 컬렉션. 기본 `memiBoardComments` */\n commentsCollection: string\n /** 좋아요 컬렉션. 기본 `memiBoardLikes` */\n likesCollection: string\n /** 보드 설정 컬렉션. 기본 `memiBoardSettings` */\n settingsCollection: string\n /** 역할 문서 컬렉션. 기본 `memiBoardUsers` */\n usersCollection: string\n auth: MemiBoardAuthOptions\n moderation: MemiBoardModerationOptions\n seo: MemiBoardSeoOptions\n}\n\nconst GLOBAL_KEY = '__MEMI_BOARD_CONFIG__' as const\n\nfunction getSharedConfig(): MemiBoardConfig {\n const g = globalThis as typeof globalThis & {\n [GLOBAL_KEY]?: MemiBoardConfig\n }\n if (!g[GLOBAL_KEY]) {\n g[GLOBAL_KEY] = reactive<MemiBoardConfig>({\n postsCollection: DEFAULT_POSTS_COLLECTION,\n commentsCollection: DEFAULT_COMMENTS_COLLECTION,\n likesCollection: DEFAULT_LIKES_COLLECTION,\n settingsCollection: DEFAULT_SETTINGS_COLLECTION,\n usersCollection: DEFAULT_USERS_COLLECTION,\n auth: { providers: ['google', 'apple'] },\n moderation: {\n enabled: true,\n model: 'gemini-3.5-flash-lite',\n onError: 'allow',\n moderateImages: false,\n localBlocklist: [],\n blockBanThreshold: 3,\n blockBanDecayMs: 24 * 60 * 60 * 1000,\n },\n seo: {\n enabled: true,\n siteName: 'Board',\n siteUrl: '',\n defaultOgImage: '',\n basePath: '/board',\n },\n })\n }\n return g[GLOBAL_KEY]\n}\n\nexport function configureMemiBoard(options: Partial<MemiBoardConfig>): void {\n const config = getSharedConfig()\n if (options.postsCollection != null && options.postsCollection !== '') {\n config.postsCollection = options.postsCollection.trim()\n }\n if (options.commentsCollection != null && options.commentsCollection !== '') {\n config.commentsCollection = options.commentsCollection.trim()\n }\n if (options.likesCollection != null && options.likesCollection !== '') {\n config.likesCollection = options.likesCollection.trim()\n }\n if (options.settingsCollection != null && options.settingsCollection !== '') {\n config.settingsCollection = options.settingsCollection.trim()\n }\n if (options.usersCollection != null && options.usersCollection !== '') {\n config.usersCollection = options.usersCollection.trim()\n }\n if (options.auth) Object.assign(config.auth, options.auth)\n if (options.moderation) Object.assign(config.moderation, options.moderation)\n if (options.seo) Object.assign(config.seo, options.seo)\n}\n\nexport function useMemiBoardConfig(): MemiBoardConfig {\n return getSharedConfig()\n}\n\nexport function useBoardPathConfig(): BoardPathConfig {\n return resolveBoardPathConfig(useMemiBoardConfig())\n}\n","/**\n * 제목 문자열을 URL-friendly slug로 변환\n * e.g. \"게시판 모듈 만들기!!\" → \"게시판-모듈-만들기\"\n */\nexport function slugify(text: string): string {\n return text\n .trim()\n .replace(/\\s+/g, '-') // 공백(연속 포함) → 하이픈\n .replace(/[^a-zA-Z0-9가-힣ㄱ-ㅎㅏ-ㅣ-]/g, '') // 허용 문자 외 제거\n .replace(/-+/g, '-') // 연속 하이픈 → 단일 하이픈\n .replace(/^-+|-+$/g, '') // 앞뒤 하이픈 제거\n}\n\n/** Storage 경로/파일명에 안전한 문자열로 변환 (한글은 유지) */\nexport function safeFileName(name: string, maxLength = 60): string {\n return name.replace(/[^a-zA-Z0-9가-힣ㄱ-ㅎ._-]/g, '-').slice(0, maxLength)\n}\n"],"mappings":"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"}
@@ -0,0 +1,9 @@
1
+ /**
2
+ * 클라이언트 전용 서브패스 — Editor.vue/Attachments.vue만 여기서 import 한다.
3
+ * heic2any(WASM glue가 문자열로 박혀 있음)와 canvas 기반 compressImage가
4
+ * 여기 들어있어서, SSR로 로드되는 memi-board/runtime(dist/index.js)에는
5
+ * 절대 섞이면 안 된다 — 안 그러면 Nitro rollup commonjs 파서가 그 청크를
6
+ * 파싱하다 실패해 프로덕션 빌드 자체가 깨진다(호출도 없는데도).
7
+ */
8
+ export { useMemiBoardStorage, EDITOR_IMAGE_MAX_BYTES, EDITOR_IMAGE_SOURCE_MAX_BYTES } from './composables/useMemiBoardStorage';
9
+ export { compressImage } from './utils/compressImage';
@@ -0,0 +1,148 @@
1
+ import { C as e, i as t, k as n, t as r } from "./slugify-CDMIDDCN.js";
2
+ import { useFirebaseApp as i } from "vuefire";
3
+ import { deleteObject as a, getDownloadURL as o, getStorage as s, ref as c, uploadBytes as l, uploadBytesResumable as u } from "firebase/storage";
4
+ //#region src/utils/compressImage.ts
5
+ async function d(e, { maxWidth: t = 400, quality: n = .8 } = {}) {
6
+ return new Promise((r, i) => {
7
+ let a = new Image(), o = URL.createObjectURL(e);
8
+ a.onload = () => {
9
+ URL.revokeObjectURL(o);
10
+ let e = Math.min(1, t / a.naturalWidth), s = document.createElement("canvas");
11
+ s.width = Math.round(a.naturalWidth * e), s.height = Math.round(a.naturalHeight * e);
12
+ let c = s.getContext("2d");
13
+ if (!c) {
14
+ i(/* @__PURE__ */ Error("Canvas를 사용할 수 없습니다"));
15
+ return;
16
+ }
17
+ c.drawImage(a, 0, 0, s.width, s.height), s.toBlob((e) => e ? r(e) : i(/* @__PURE__ */ Error("압축 실패")), "image/jpeg", n);
18
+ }, a.onerror = () => {
19
+ URL.revokeObjectURL(o), i(/* @__PURE__ */ Error("이미지 로드 실패"));
20
+ }, a.src = o;
21
+ });
22
+ }
23
+ //#endregion
24
+ //#region src/composables/useMemiBoardStorage.ts
25
+ var f = 5242880, p = 26214400;
26
+ function m(e) {
27
+ let t = (e.type || "").toLowerCase(), n = e.name.toLowerCase();
28
+ return t === "image/heic" || t === "image/heif" || n.endsWith(".heic") || n.endsWith(".heif");
29
+ }
30
+ async function h(e, t = .85) {
31
+ let { default: r } = await import("./heic2any-BHVEj2CD.js").then((e) => /* @__PURE__ */ n(e.default, 1)), i = await r({
32
+ blob: e,
33
+ toType: "image/jpeg",
34
+ quality: t
35
+ }), a = Array.isArray(i) ? i[0] : i;
36
+ if (!(a instanceof Blob)) throw Error("HEIC 변환 결과가 비어 있습니다.");
37
+ return a;
38
+ }
39
+ async function g(e) {
40
+ if (e.size <= 5242880) return e;
41
+ let t = await d(e, {
42
+ maxWidth: 2560,
43
+ quality: .85
44
+ });
45
+ if (t.size > 5242880 && (t = await d(e, {
46
+ maxWidth: 2048,
47
+ quality: .75
48
+ })), t.size > 5242880) throw Error("압축 후에도 이미지가 5MB를 초과합니다. 더 작은 이미지를 선택해 주세요.");
49
+ return t;
50
+ }
51
+ async function _(e) {
52
+ if (e.size > 26214400) throw Error(`원본 이미지는 ${p / 1024 / 1024}MB 이하여야 합니다.`);
53
+ if (m(e)) try {
54
+ return {
55
+ blob: await g(await h(e, .85)),
56
+ compressed: !0
57
+ };
58
+ } catch (t) {
59
+ if (t instanceof Error && t.message.includes("압축 후에도")) throw t;
60
+ try {
61
+ return {
62
+ blob: await g(await d(e, {
63
+ maxWidth: 2560,
64
+ quality: .85
65
+ })),
66
+ compressed: !0
67
+ };
68
+ } catch {
69
+ throw console.error("[memi-board] HEIC convert failed", t), Error("HEIC 사진을 변환하지 못했습니다. 잠시 후 다시 시도하거나 JPG로 저장해 올려 주세요.");
70
+ }
71
+ }
72
+ if (e.size <= 5242880) return {
73
+ blob: e,
74
+ compressed: !1
75
+ };
76
+ try {
77
+ return {
78
+ blob: await g(e),
79
+ compressed: !0
80
+ };
81
+ } catch (e) {
82
+ throw e instanceof Error && e.message.includes("압축 후에도") ? e : Error("이 이미지 형식은 브라우저에서 최적화할 수 없습니다. JPG, PNG 또는 WebP로 변환해 주세요.");
83
+ }
84
+ }
85
+ function v() {
86
+ let n = () => t(), f = i();
87
+ function p(t, i, a) {
88
+ let l = s(f), d = `${e(n(), i)}/attachments/${Date.now()}-${r(t.name)}`, p = c(l, d), m = u(p, t, {
89
+ contentType: t.type || "application/octet-stream",
90
+ contentDisposition: `attachment; filename*=UTF-8''${encodeURIComponent(t.name)}`
91
+ });
92
+ return {
93
+ promise: new Promise((e, n) => {
94
+ m.on("state_changed", (e) => {
95
+ a && e.totalBytes > 0 && a(e.bytesTransferred / e.totalBytes);
96
+ }, n, async () => {
97
+ let n = await o(p);
98
+ e({
99
+ name: t.name,
100
+ url: n,
101
+ path: d,
102
+ size: t.size,
103
+ type: t.type
104
+ });
105
+ });
106
+ }),
107
+ cancel: () => m.cancel()
108
+ };
109
+ }
110
+ async function m(e) {
111
+ let t = s(f);
112
+ await a(c(t, e.path)).catch(() => {});
113
+ }
114
+ async function h(t, i) {
115
+ if (!t.type.startsWith("image/")) throw Error("이미지 파일만 업로드할 수 있습니다.");
116
+ let a = await _(t), u = s(f), p = (a.compressed ? "jpg" : t.name.split(".").pop() || t.type.split("/")[1] || "png").replace(/[^a-zA-Z0-9]/g, "").slice(0, 8) || "png", m = r(t.name.replace(/\.[^.]+$/, "") || "image", 40), h = `${Date.now()}-${m || "image"}`, g = i || `new-${Date.now()}`, v = e(n(), g), y = `${v}/images/${h}.${p}`, b = c(u, y);
117
+ await l(b, a.blob, { contentType: a.blob.type || "image/jpeg" });
118
+ let x = await o(b), S = await d(a.blob, {
119
+ maxWidth: 400,
120
+ quality: .8
121
+ }), C = `${v}/images/thumbnails/${h}.jpg`, w = c(u, C);
122
+ return await l(w, S, { contentType: "image/jpeg" }), {
123
+ originalUrl: x,
124
+ originalPath: y,
125
+ thumbnailUrl: await o(w),
126
+ thumbnailPath: C
127
+ };
128
+ }
129
+ async function g(e) {
130
+ let t = s(f);
131
+ await Promise.all([a(c(t, e.originalPath)).catch(() => {}), a(c(t, e.thumbnailPath)).catch(() => {})]);
132
+ }
133
+ async function v(e, t) {
134
+ let r = s(f), i = `${n().usersCollection}/${e}/avatar.jpg`, a = c(r, i);
135
+ return await l(a, t, { contentType: t.type || "image/jpeg" }), o(a);
136
+ }
137
+ return {
138
+ uploadAttachment: p,
139
+ deleteAttachment: m,
140
+ uploadEditorImage: h,
141
+ deleteEditorImage: g,
142
+ uploadAvatar: v
143
+ };
144
+ }
145
+ //#endregion
146
+ export { f as EDITOR_IMAGE_MAX_BYTES, p as EDITOR_IMAGE_SOURCE_MAX_BYTES, d as compressImage, v as useMemiBoardStorage };
147
+
148
+ //# sourceMappingURL=storage.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"storage.js","names":[],"sources":["../src/utils/compressImage.ts","../src/composables/useMemiBoardStorage.ts"],"sourcesContent":["/**\n * Canvas API로 썸네일용 이미지 압축 (shineb 와 동일).\n */\nexport async function compressImage(\n file: File | Blob,\n { maxWidth = 400, quality = 0.8 }: { maxWidth?: number, quality?: number } = {},\n): Promise<Blob> {\n return new Promise((resolve, reject) => {\n const img = new Image()\n const objectUrl = URL.createObjectURL(file)\n\n img.onload = () => {\n URL.revokeObjectURL(objectUrl)\n\n const ratio = Math.min(1, maxWidth / img.naturalWidth)\n const canvas = document.createElement('canvas')\n canvas.width = Math.round(img.naturalWidth * ratio)\n canvas.height = Math.round(img.naturalHeight * ratio)\n\n const ctx = canvas.getContext('2d')\n if (!ctx) {\n reject(new Error('Canvas를 사용할 수 없습니다'))\n return\n }\n ctx.drawImage(img, 0, 0, canvas.width, canvas.height)\n\n canvas.toBlob(\n blob => (blob ? resolve(blob) : reject(new Error('압축 실패'))),\n 'image/jpeg',\n quality,\n )\n }\n\n img.onerror = () => {\n URL.revokeObjectURL(objectUrl)\n reject(new Error('이미지 로드 실패'))\n }\n\n img.src = objectUrl\n })\n}\n","import {\n getStorage,\n ref as storageRef,\n uploadBytes,\n uploadBytesResumable,\n getDownloadURL,\n deleteObject,\n} from 'firebase/storage'\nimport type { UploadTask } from 'firebase/storage'\nimport { useFirebaseApp } from 'vuefire'\nimport { safeFileName } from '../utils/slugify'\nimport { compressImage } from '../utils/compressImage'\nimport { useBoardPathConfig } from '../config'\nimport { postStorageFolder } from '../utils/boardPaths'\nimport type { Attachment, EditorImageEntry } from '../types'\n\n/** 에디터 이미지 최대 크기 (바이트) */\nexport const EDITOR_IMAGE_MAX_BYTES = 5 * 1024 * 1024\n/** 휴대폰 원본 이미지 선택 허용 크기. 큰 이미지는 업로드 전에 최적화한다. */\nexport const EDITOR_IMAGE_SOURCE_MAX_BYTES = 25 * 1024 * 1024\n\nfunction isHeicLike(file: File): boolean {\n const type = (file.type || '').toLowerCase()\n const name = file.name.toLowerCase()\n return type === 'image/heic'\n || type === 'image/heif'\n || name.endsWith('.heic')\n || name.endsWith('.heif')\n}\n\n/** HEIC/HEIF → JPEG (heic2any, 동적 import) */\nasync function heicToJpegBlob(file: File, quality = 0.85): Promise<Blob> {\n const { default: heic2any } = await import('heic2any')\n const result = await heic2any({\n blob: file,\n toType: 'image/jpeg',\n quality,\n })\n const blob = Array.isArray(result) ? result[0] : result\n if (!(blob instanceof Blob)) {\n throw new Error('HEIC 변환 결과가 비어 있습니다.')\n }\n return blob\n}\n\nasync function resizeJpegIfNeeded(blob: Blob): Promise<Blob> {\n if (blob.size <= EDITOR_IMAGE_MAX_BYTES) return blob\n let next = await compressImage(blob, { maxWidth: 2560, quality: 0.85 })\n if (next.size > EDITOR_IMAGE_MAX_BYTES) {\n next = await compressImage(blob, { maxWidth: 2048, quality: 0.75 })\n }\n if (next.size > EDITOR_IMAGE_MAX_BYTES) {\n throw new Error('압축 후에도 이미지가 5MB를 초과합니다. 더 작은 이미지를 선택해 주세요.')\n }\n return next\n}\n\n/**\n * 에디터 업로드용 정규화.\n * - 5MB 초과 → 리사이즈\n * - HEIC/HEIF → heic2any 로 JPEG 변환 후 필요 시 리사이즈\n */\nasync function optimizeEditorImage(file: File): Promise<{ blob: File | Blob, compressed: boolean }> {\n if (file.size > EDITOR_IMAGE_SOURCE_MAX_BYTES) {\n throw new Error(`원본 이미지는 ${EDITOR_IMAGE_SOURCE_MAX_BYTES / 1024 / 1024}MB 이하여야 합니다.`)\n }\n\n if (isHeicLike(file)) {\n try {\n const jpeg = await heicToJpegBlob(file, 0.85)\n const blob = await resizeJpegIfNeeded(jpeg)\n return { blob, compressed: true }\n }\n catch (cause) {\n if (cause instanceof Error && cause.message.includes('압축 후에도')) throw cause\n // Safari 등 canvas 디코드가 되는 환경 폴백\n try {\n const blob = await resizeJpegIfNeeded(await compressImage(file, { maxWidth: 2560, quality: 0.85 }))\n return { blob, compressed: true }\n }\n catch {\n console.error('[memi-board] HEIC convert failed', cause)\n throw new Error('HEIC 사진을 변환하지 못했습니다. 잠시 후 다시 시도하거나 JPG로 저장해 올려 주세요.')\n }\n }\n }\n\n if (file.size <= EDITOR_IMAGE_MAX_BYTES) {\n return { blob: file, compressed: false }\n }\n\n try {\n const blob = await resizeJpegIfNeeded(file)\n return { blob, compressed: true }\n }\n catch (cause) {\n if (cause instanceof Error && cause.message.includes('압축 후에도')) throw cause\n throw new Error('이 이미지 형식은 브라우저에서 최적화할 수 없습니다. JPG, PNG 또는 WebP로 변환해 주세요.')\n }\n}\n\nexport function useMemiBoardStorage() {\n const cfg = () => useBoardPathConfig()\n const app = useFirebaseApp()\n\n /** postId는 작성 화면 진입 시 미리 생성한 Firestore 자동 ID를 사용한다. */\n function uploadAttachment(\n file: File,\n postId: string,\n onProgress?: (ratio: number) => void,\n ): { promise: Promise<Attachment>, cancel: () => void } {\n const storage = getStorage(app)\n const path = `${postStorageFolder(cfg(), postId)}/attachments/${Date.now()}-${safeFileName(file.name)}`\n const fileRef = storageRef(storage, path)\n const task: UploadTask = uploadBytesResumable(fileRef, file, {\n contentType: file.type || 'application/octet-stream',\n contentDisposition: `attachment; filename*=UTF-8''${encodeURIComponent(file.name)}`,\n })\n\n const promise = new Promise<Attachment>((resolve, reject) => {\n task.on(\n 'state_changed',\n (snapshot) => {\n if (onProgress && snapshot.totalBytes > 0) {\n onProgress(snapshot.bytesTransferred / snapshot.totalBytes)\n }\n },\n reject,\n async () => {\n const url = await getDownloadURL(fileRef)\n resolve({ name: file.name, url, path, size: file.size, type: file.type })\n },\n )\n })\n\n return { promise, cancel: () => task.cancel() }\n }\n\n async function deleteAttachment(attachment: Pick<Attachment, 'path'>): Promise<void> {\n const storage = getStorage(app)\n await deleteObject(storageRef(storage, attachment.path)).catch(() => {})\n }\n\n /**\n * 에디터 본문 이미지: 원본 + 400px JPEG 썸네일.\n * Storage:\n * `memiBoardPosts/{postId}/images/{ts}-{name}.ext`\n * `memiBoardPosts/{postId}/images/thumbnails/{ts}-{name}.jpg`\n */\n async function uploadEditorImage(file: File, postId: string): Promise<EditorImageEntry> {\n if (!file.type.startsWith('image/')) {\n throw new Error('이미지 파일만 업로드할 수 있습니다.')\n }\n const optimized = await optimizeEditorImage(file)\n\n const storage = getStorage(app)\n const ext = (optimized.compressed ? 'jpg' : (file.name.split('.').pop() || file.type.split('/')[1] || 'png'))\n .replace(/[^a-zA-Z0-9]/g, '')\n .slice(0, 8) || 'png'\n const safeName = safeFileName(file.name.replace(/\\.[^.]+$/, '') || 'image', 40)\n const baseName = `${Date.now()}-${safeName || 'image'}`\n const ns = postId || `new-${Date.now()}`\n const folder = postStorageFolder(cfg(), ns)\n\n const originalPath = `${folder}/images/${baseName}.${ext}`\n const originalRef = storageRef(storage, originalPath)\n await uploadBytes(originalRef, optimized.blob, {\n contentType: optimized.blob.type || 'image/jpeg',\n })\n const originalUrl = await getDownloadURL(originalRef)\n\n const thumbBlob = await compressImage(optimized.blob, { maxWidth: 400, quality: 0.8 })\n const thumbnailPath = `${folder}/images/thumbnails/${baseName}.jpg`\n const thumbRef = storageRef(storage, thumbnailPath)\n await uploadBytes(thumbRef, thumbBlob, { contentType: 'image/jpeg' })\n const thumbnailUrl = await getDownloadURL(thumbRef)\n\n return { originalUrl, originalPath, thumbnailUrl, thumbnailPath }\n }\n\n /** 원본·썸네일 쌍 삭제 (고아 정리·첨부 삭제용) */\n async function deleteEditorImage(entry: Pick<EditorImageEntry, 'originalPath' | 'thumbnailPath'>): Promise<void> {\n const storage = getStorage(app)\n await Promise.all([\n deleteObject(storageRef(storage, entry.originalPath)).catch(() => {}),\n deleteObject(storageRef(storage, entry.thumbnailPath)).catch(() => {}),\n ])\n }\n\n /**\n * 프로필 사진. 경로가 uid로 고정돼 있어 다시 올리면 그냥 덮어쓴다(고아 파일 없음).\n * 파일은 이미 크롭 단계에서 정사각형 JPEG로 만들어져 오므로 여기선 그대로 업로드한다.\n */\n async function uploadAvatar(uid: string, file: File): Promise<string> {\n const storage = getStorage(app)\n const path = `${cfg().usersCollection}/${uid}/avatar.jpg`\n const fileRef = storageRef(storage, path)\n await uploadBytes(fileRef, file, { contentType: file.type || 'image/jpeg' })\n return getDownloadURL(fileRef)\n }\n\n return {\n uploadAttachment,\n deleteAttachment,\n uploadEditorImage,\n deleteEditorImage,\n uploadAvatar,\n }\n}\n"],"mappings":"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"}
package/dist/types.d.ts CHANGED
@@ -8,7 +8,7 @@ export interface Attachment {
8
8
  }
9
9
  /**
10
10
  * 에디터 본문 이미지 업로드 결과 (원본 + 썸네일).
11
- * Storage: `memiBoards/{boardId}/posts/{postId}/images/...` · `.../images/thumbnails/...`
11
+ * Storage: `memiBoardPosts/{postId}/images/...` · `.../images/thumbnails/...`
12
12
  * 본문 markdown 에는 originalUrl 만 넣고, 썸네일은 목록 카드 등에 활용 가능.
13
13
  * 수정 중 버려진 파일은 호스트 스케줄러로 정리 (본문에 없는 path).
14
14
  */
@@ -22,6 +22,8 @@ export interface EditorImageEntry {
22
22
  export type ModerationStatus = 'approved';
23
23
  export interface PostModel {
24
24
  id?: string;
25
+ /** 어느 보드(memiBoardSettings/{boardId})에 속한 글인지 — flat 컬렉션이라 경로 대신 필드로 스코핑한다. */
26
+ boardId: string;
25
27
  /** 카테고리 내 URL 식별자. 최초 작성 시 생성되고 제목 수정 시에도 유지된다. */
26
28
  slug: string;
27
29
  title: string;
@@ -32,14 +34,14 @@ export interface PostModel {
32
34
  videoUrl?: string;
33
35
  tags?: string[];
34
36
  /**
35
- * @deprecated 카테고리 개념 제거 — 글은 memiBoards/{boardId}/posts 에만 존재.
37
+ * @deprecated 카테고리 개념 제거 — boardId 로 대체.
36
38
  * 레거시 읽기 호환용으로만 남을 수 있음.
37
39
  */
38
40
  category?: string;
39
41
  attachments?: Attachment[];
40
42
  /** 클라이언트 batch로 증감하는 UI 편의 필드 — 보안 판단에 쓰지 않는다. */
41
43
  commentCount: number;
42
- /** 클라이언트 트랜잭션으로 증감하는 UI 편의 필드 — 보안 판단에 쓰지 않는다. 실제 좋아요 여부는 likes/{uid} 서브컬렉션이 기준. */
44
+ /** 클라이언트 트랜잭션으로 증감하는 UI 편의 필드 — 보안 판단에 쓰지 않는다. 실제 좋아요 여부는 memiBoardLikes 컬렉션이 기준. */
43
45
  likeCount: number;
44
46
  /**
45
47
  * 조회수. 로그인 없이 +1 가능(rules 가 viewCount 단독 증가만 허용).
@@ -69,13 +71,17 @@ export interface PostModel {
69
71
  export interface PostDetail extends PostModel {
70
72
  content: string;
71
73
  }
72
- /** 보드 경계를 넘는 작성자별 글 목록(예: /board-user/{uid})에서만 쓰는 형태 — 어느 보드 글인지 함께 담는다. */
73
- export interface UserPostModel extends PostModel {
74
- boardId: string;
75
- }
74
+ /**
75
+ * @deprecated PostModel 에 boardId 가 이미 있어 더 이상 별도 타입이 필요 없다.
76
+ * 보드 경계를 넘는 작성자별 글 목록(예: /board-user/{uid}) 등에서 PostModel 을 그대로 쓰면 된다.
77
+ */
78
+ export type UserPostModel = PostModel;
76
79
  export interface CommentModel {
77
80
  id?: string;
81
+ /** 속한 글. flat 컬렉션이라 경로 대신 필드로 스코핑한다. */
78
82
  postId: string;
83
+ /** 부모 글의 boardId — 생성 시 클라이언트가 채우고, rules 가 부모 글과 일치하는지 검증한다. */
84
+ boardId: string;
79
85
  body: string;
80
86
  authorUid: string;
81
87
  authorName: string | null;
@@ -101,14 +107,31 @@ export interface CommentModel {
101
107
  isReply?: boolean;
102
108
  }
103
109
  export type BoardUserRole = 'admin' | 'staff' | 'user';
110
+ /** memiBoardUsers/{uid} — 이메일만 빼고 전부 공개 프로필 정보다(누구나 읽을 수 있음). */
104
111
  export interface BoardUserModel {
105
112
  id: string;
106
113
  role: BoardUserRole;
107
114
  displayName?: string | null;
108
- email?: string | null;
109
115
  photoURL?: string | null;
110
116
  moderationBlockCount?: number;
111
117
  updatedAt?: Timestamp;
118
+ /** 최초 가입일. 기존 사용자는 비어 있고, 백필 스크립트나 다음 로그인 시 채운다. */
119
+ joinedAt?: Timestamp;
120
+ /** 최근 방문일. 기존 사용자는 비어 있고, 백필 스크립트나 다음 로그인 시 채운다. */
121
+ lastVisitAt?: Timestamp;
122
+ /** 프로필 자기소개. */
123
+ bio?: string | null;
124
+ }
125
+ /** memiBoardUsers/{uid}/private/info — 본인·관리자만 읽는 비공개 정보. */
126
+ export interface BoardUserPrivateModel {
127
+ email?: string | null;
128
+ }
129
+ export type AuthorMemoSentiment = 'good' | 'bad';
130
+ /** memiBoardUsers/{내uid}/memos/{targetUid} — 본인만 보는 작성자 메모. targetUid는 문서 ID라 필드로 안 둔다. */
131
+ export interface AuthorMemoModel {
132
+ text: string;
133
+ sentiment?: AuthorMemoSentiment;
134
+ updatedAt?: Timestamp;
112
135
  }
113
136
  export type BoardListView = 'default' | 'dense' | 'image' | 'video';
114
137
  export type BoardWriteRole = 'user' | 'staff' | 'admin';
@@ -116,7 +139,7 @@ export type BoardWriteRole = 'user' | 'staff' | 'admin';
116
139
  export type BoardVisibility = 'public' | 'hidden';
117
140
  /**
118
141
  * 보드 1개 메타 (= 예전 BoardCategory).
119
- * id 가 곧 boardId — memiBoards/{id}/settings/config 에 저장.
142
+ * id 가 곧 boardId — memiBoardSettings/{id} 에 저장.
120
143
  */
121
144
  export interface BoardModel {
122
145
  /** boardId (예: 'notice', 'free') */
@@ -141,10 +164,18 @@ export interface BoardModel {
141
164
  }
142
165
  /** @deprecated BoardModel 사용 — 카테고리 개념 제거, id = boardId */
143
166
  export type BoardCategory = BoardModel;
144
- /** memiBoards/{boardId}/settings/config */
167
+ /** memiBoardSettings/{boardId} */
145
168
  export interface BoardSettingsModel extends Omit<BoardModel, 'id'> {
146
169
  updatedAt?: Timestamp;
147
170
  }
171
+ /** memiBoardLikes/{postId}_{uid} — "내가 좋아요한 글" 등 uid 기준 교차 조회용 flat 컬렉션. */
172
+ export interface BoardLikeModel {
173
+ id?: string;
174
+ uid: string;
175
+ postId: string;
176
+ boardId: string;
177
+ createdAt: Timestamp;
178
+ }
148
179
  /** 검열이 어느 단계에서 끝났는지 (호스트/로깅용, UI 노출 없음) */
149
180
  export type ModerationVia = 'empty' | 'disabled' | 'local' | 'ai' | 'restricted' | 'ai-error-allow' | 'ai-error-block';
150
181
  export interface ModerationResult {