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
@@ -9,7 +9,9 @@ withDefaults(defineProps<{
9
9
  postTo: (post: PostModel) => string | undefined
10
10
  now: number
11
11
  showCategory?: boolean
12
+ authorProfileTo?: (authorUid: string) => string | undefined
12
13
  authorPostsTo?: (authorUid: string) => string | undefined
14
+ authorCommentsTo?: (authorUid: string) => string | undefined
13
15
  }>(), { showCategory: true })
14
16
  const emit = defineEmits<{ select: [post: PostModel] }>()
15
17
  const { categoryLabel } = useMemiBoardSettings()
@@ -36,22 +38,6 @@ function image(post: PostModel): string | undefined {
36
38
  class="flex min-w-0 flex-1 text-left transition-colors hover:bg-elevated/50"
37
39
  @click="!postTo(post) && emit('select', post)"
38
40
  >
39
- <!-- 카드 좌·상·하 밀착, 가로는 고정 / 세로는 행 높이 전체 -->
40
- <div class="w-[4.5rem] shrink-0 self-stretch bg-elevated">
41
- <img
42
- v-if="image(post)"
43
- :src="image(post)"
44
- :alt="post.title"
45
- class="h-full w-full object-cover"
46
- >
47
- <div
48
- v-else
49
- class="flex h-full min-h-[4.5rem] w-full items-center justify-center text-muted"
50
- >
51
- <UIcon name="i-lucide-image" class="size-6" />
52
- </div>
53
- </div>
54
-
55
41
  <div class="min-w-0 flex-1 flex flex-col justify-center gap-1 overflow-hidden py-2 pl-3 pr-2">
56
42
  <div class="flex min-w-0 items-start gap-2">
57
43
  <UBadge
@@ -83,39 +69,56 @@ function image(post: PostModel): string | undefined {
83
69
  </div>
84
70
  <p
85
71
  v-if="post.summary"
86
- class="min-w-0 line-clamp-2 break-words text-sm leading-snug text-muted"
72
+ class="min-w-0 line-clamp-2 break-words whitespace-pre-line text-sm leading-snug text-muted"
87
73
  >
88
74
  {{ post.summary }}
89
75
  </p>
90
- <div v-if="post.attachments?.length" class="flex items-center gap-1 text-xs text-muted">
91
- <UIcon name="i-lucide-paperclip" class="size-3" />{{ post.attachments.length }}
92
- </div>
76
+ </div>
77
+
78
+ <div
79
+ v-if="image(post)"
80
+ class="my-2 mr-2 size-[4.5rem] shrink-0 self-center overflow-hidden rounded-full bg-elevated"
81
+ >
82
+ <img
83
+ :src="image(post)"
84
+ :alt="post.title"
85
+ class="h-full w-full object-cover"
86
+ >
93
87
  </div>
94
88
  </component>
95
89
 
96
- <div class="flex w-[5.5rem] shrink-0 flex-col items-end justify-center gap-1 py-2 pr-3 text-right text-xs leading-snug text-muted sm:w-24">
90
+ <div class="flex w-[120px] shrink-0 flex-col items-end justify-center gap-1 py-2 pr-3 text-right text-xs leading-snug text-muted">
97
91
  <MemiBoardAuthorMenu
98
92
  :author-uid="post.authorUid"
99
93
  :author-name="post.authorName"
100
94
  :author-photo="post.authorPhoto"
101
95
  :author-posts-to="authorPostsTo"
102
- :show-avatar="false"
96
+ :author-profile-to="authorProfileTo"
97
+ :author-comments-to="authorCommentsTo"
98
+ :show-avatar="true"
103
99
  truncate
104
100
  class="max-w-full"
105
101
  />
106
- <UTooltip
107
- :delay-duration="0"
108
- :text="formatTimestampDetails(post.createdAt, post.updatedAt).join('\n')"
109
- :ui="{ content: 'h-auto w-max py-2', text: 'whitespace-pre-line overflow-visible' }"
110
- >
102
+ <UPopover :content="{ side: 'top' }" :ui="{ content: 'h-auto w-max' }">
111
103
  <time
112
104
  :datetime="post.createdAt?.toDate?.().toISOString()"
113
- class="cursor-help text-xs leading-snug text-muted"
105
+ class="cursor-pointer text-xs leading-snug text-muted"
114
106
  >
115
107
  {{ formatRelativeDate(post.createdAt, now) }}
116
108
  </time>
117
- </UTooltip>
109
+ <template #content>
110
+ <p class="whitespace-pre-line px-3 py-2 text-xs">
111
+ {{ formatTimestampDetails(post.createdAt, post.updatedAt).join('\n') }}
112
+ </p>
113
+ </template>
114
+ </UPopover>
118
115
  <div class="flex flex-wrap items-center justify-end gap-x-2 gap-y-0.5 text-xs leading-snug text-muted">
116
+ <span
117
+ v-if="post.attachments?.length"
118
+ class="inline-flex items-center gap-0.5 tabular-nums"
119
+ >
120
+ <UIcon name="i-lucide-paperclip" class="size-3 shrink-0" />{{ post.attachments.length }}
121
+ </span>
119
122
  <span class="inline-flex items-center gap-0.5 tabular-nums">
120
123
  <UIcon name="i-lucide-eye" class="size-3 shrink-0" />{{ post.viewCount ?? 0 }}
121
124
  </span>
@@ -9,7 +9,9 @@ withDefaults(defineProps<{
9
9
  postTo: (post: PostModel) => string | undefined
10
10
  now: number
11
11
  showCategory?: boolean
12
+ authorProfileTo?: (authorUid: string) => string | undefined
12
13
  authorPostsTo?: (authorUid: string) => string | undefined
14
+ authorCommentsTo?: (authorUid: string) => string | undefined
13
15
  }>(), { showCategory: true })
14
16
  const emit = defineEmits<{ select: [post: PostModel] }>()
15
17
  const { categoryLabel } = useMemiBoardSettings()
@@ -21,108 +23,95 @@ function thumb(post: PostModel): string | undefined {
21
23
  </script>
22
24
 
23
25
  <template>
24
- <!--
25
- 조밀 = 타임라인 스트림.
26
- 레일 위 원형 노드 자리에 썸네일, 제목 한 줄 + 메타.
27
- -->
28
- <div class="relative">
29
- <!-- 세로 레일 (썸네일 중심 기준) -->
30
- <div
31
- class="pointer-events-none absolute bottom-4 left-5 top-4 w-px bg-gradient-to-b from-primary/40 via-default to-primary/20"
32
- aria-hidden="true"
33
- />
26
+ <!-- 조밀 = 커뮤니티 게시판 스타일 한 줄 목록. 우측에 작은 사각 썸네일(있을 때만). -->
27
+ <ul class="flex flex-col divide-y divide-default">
28
+ <li v-for="post in posts" :key="post.id" class="flex flex-col gap-0.5 px-2 py-1.5">
29
+ <!-- 제목+썸네일만 클릭 시 글로 이동 — 메타 줄의 시간 팝오버·작성자 메뉴는 별도 인터랙션이라 링크 밖에 둔다 -->
30
+ <component
31
+ :is="postTo(post) ? NuxtLink : 'button'"
32
+ :to="postTo(post)"
33
+ class="group relative flex w-full items-center gap-2 rounded-lg text-left transition-colors hover:bg-elevated/60"
34
+ @click="!postTo(post) && emit('select', post)"
35
+ >
36
+ <div class="flex min-w-0 flex-1 items-baseline gap-1.5">
37
+ <UBadge
38
+ v-if="post.isPublished === false"
39
+ label="초안"
40
+ color="warning"
41
+ variant="subtle"
42
+ size="sm"
43
+ class="shrink-0"
44
+ />
45
+ <h3 class="min-w-0 truncate text-sm font-medium leading-snug text-highlighted transition-colors group-hover:text-primary">
46
+ {{ post.title?.trim() || post.summary || '사진' }}
47
+ </h3>
48
+ <span
49
+ v-if="(post.commentCount ?? 0) > 0"
50
+ class="mt-0.5 inline-flex shrink-0 items-center rounded-full bg-primary/10 px-1.5 py-px text-[11px] font-semibold tabular-nums text-primary"
51
+ >
52
+ {{ post.commentCount }}
53
+ </span>
54
+ </div>
34
55
 
35
- <ul class="flex flex-col gap-0.5">
36
- <li v-for="post in posts" :key="post.id" class="flex flex-col gap-1 py-1.5 pr-2 pl-0">
37
- <!-- 썸네일+제목만 클릭 시 글로 이동 — 작성자 메뉴는 별도 인터랙션이라 링크 밖에 둔다 -->
38
- <component
39
- :is="postTo(post) ? NuxtLink : 'button'"
40
- :to="postTo(post)"
41
- class="group relative flex w-full items-center gap-3 rounded-lg text-left transition-colors hover:bg-elevated/60"
42
- @click="!postTo(post) && emit('select', post)"
56
+ <div
57
+ v-if="thumb(post)"
58
+ class="size-12 shrink-0 overflow-hidden rounded-md bg-elevated"
43
59
  >
44
- <!-- 레일 위 원형 썸네일 (이미지 없으면 플레이스홀더) -->
45
- <div
46
- class="relative z-10 size-10 shrink-0 overflow-hidden rounded-full bg-elevated ring-2 ring-default shadow-sm transition duration-300 group-hover:ring-primary/50"
60
+ <img
61
+ :src="thumb(post)"
62
+ :alt="post.title?.trim() || post.summary || '사진'"
63
+ class="size-full object-cover"
47
64
  >
48
- <img
49
- v-if="thumb(post)"
50
- :src="thumb(post)"
51
- :alt="post.title?.trim() || post.summary || '사진'"
52
- class="size-full object-cover transition duration-300 group-hover:scale-110"
53
- >
54
- <div
55
- v-else
56
- class="flex size-full items-center justify-center text-muted"
57
- >
58
- <UIcon name="i-lucide-file-text" class="size-4" />
59
- </div>
60
- </div>
61
-
62
- <!-- 제목 -->
63
- <div class="flex min-w-0 flex-1 items-center gap-1.5">
64
- <UBadge
65
- v-if="post.isPublished === false"
66
- label="초안"
67
- color="warning"
68
- variant="subtle"
69
- size="sm"
70
- class="shrink-0"
71
- />
72
- <h3 class="min-w-0 truncate text-sm font-medium leading-snug text-highlighted transition-colors group-hover:text-primary">
73
- {{ post.title?.trim() || post.summary || '사진' }}
74
- </h3>
75
- <span
76
- v-if="(post.commentCount ?? 0) > 0"
77
- class="inline-flex shrink-0 items-center rounded-full bg-primary/10 px-1.5 py-px text-[11px] font-semibold tabular-nums text-primary"
78
- >
79
- {{ post.commentCount }}
80
- </span>
81
- </div>
82
- </component>
65
+ </div>
66
+ </component>
83
67
 
84
- <!-- 메타: 썸네일 폭(size-10 + gap-3)만큼 들여서 제목 아래로 맞춤 -->
85
- <div class="ml-[3.25rem] flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] leading-none text-muted">
86
- <span class="font-medium text-toned">
87
- <MemiBoardAuthorMenu
88
- :author-uid="post.authorUid"
89
- :author-name="post.authorName"
90
- :author-photo="post.authorPhoto"
91
- :author-posts-to="authorPostsTo"
92
- :show-avatar="false"
93
- />
94
- </span>
95
- <span class="text-default/40" aria-hidden="true">·</span>
96
- <UTooltip
97
- :delay-duration="0"
98
- :text="formatTimestampDetails(post.createdAt, post.updatedAt).join('\n')"
99
- :ui="{ content: 'h-auto w-max py-2', text: 'whitespace-pre-line overflow-visible' }"
68
+ <div class="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-[11px] leading-none text-muted">
69
+ <UPopover :content="{ side: 'top' }" :ui="{ content: 'h-auto w-max' }">
70
+ <time
71
+ :datetime="post.createdAt?.toDate?.().toISOString()"
72
+ class="cursor-pointer tabular-nums"
100
73
  >
101
- <time
102
- :datetime="post.createdAt?.toDate?.().toISOString()"
103
- class="cursor-help tabular-nums"
104
- >
105
- {{ formatRelativeDate(post.createdAt, now) }}
106
- </time>
107
- </UTooltip>
108
- <span class="text-default/40" aria-hidden="true">·</span>
74
+ {{ formatRelativeDate(post.createdAt, now) }}
75
+ </time>
76
+ <template #content>
77
+ <p class="whitespace-pre-line px-3 py-2 text-xs">
78
+ {{ formatTimestampDetails(post.createdAt, post.updatedAt).join('\n') }}
79
+ </p>
80
+ </template>
81
+ </UPopover>
82
+ <span class="text-default/40" aria-hidden="true">|</span>
83
+ <span class="font-medium text-toned">
84
+ <MemiBoardAuthorMenu
85
+ :author-uid="post.authorUid"
86
+ :author-name="post.authorName"
87
+ :author-photo="post.authorPhoto"
88
+ :author-posts-to="authorPostsTo"
89
+ :author-profile-to="authorProfileTo"
90
+ :author-comments-to="authorCommentsTo"
91
+ :show-avatar="false"
92
+ />
93
+ </span>
94
+ <span class="text-default/40" aria-hidden="true">|</span>
95
+ <span
96
+ v-if="post.attachments?.length"
97
+ class="inline-flex items-center gap-0.5 tabular-nums"
98
+ >
99
+ <UIcon name="i-lucide-paperclip" class="size-3 shrink-0" />{{ post.attachments.length }}
100
+ </span>
101
+ <span class="inline-flex items-center gap-0.5 tabular-nums">
102
+ <UIcon name="i-lucide-eye" class="size-3 shrink-0" />{{ post.viewCount ?? 0 }}
103
+ </span>
104
+ <template v-if="(post.likeCount ?? 0) > 0">
105
+ <span class="text-default/40" aria-hidden="true">|</span>
109
106
  <span class="inline-flex items-center gap-0.5 tabular-nums">
110
- <UIcon name="i-lucide-eye" class="size-3" />
111
- {{ post.viewCount ?? 0 }}
107
+ 추천 {{ post.likeCount }}
112
108
  </span>
113
- <template v-if="(post.likeCount ?? 0) > 0">
114
- <span class="text-default/40" aria-hidden="true">·</span>
115
- <span class="inline-flex items-center gap-0.5 tabular-nums">
116
- <UIcon name="i-lucide-heart" class="size-3 text-primary/80" />
117
- {{ post.likeCount }}
118
- </span>
119
- </template>
120
- <template v-if="post.category && showCategory">
121
- <span class="text-default/40" aria-hidden="true">·</span>
122
- <span class="truncate text-muted">{{ categoryLabel(post.category) }}</span>
123
- </template>
124
- </div>
125
- </li>
126
- </ul>
127
- </div>
109
+ </template>
110
+ <template v-if="post.category && showCategory">
111
+ <span class="text-default/40" aria-hidden="true">|</span>
112
+ <span class="truncate text-muted">[{{ categoryLabel(post.category) }}]</span>
113
+ </template>
114
+ </div>
115
+ </li>
116
+ </ul>
128
117
  </template>
@@ -8,7 +8,9 @@ defineProps<{
8
8
  posts: PostModel[]
9
9
  postTo: (post: PostModel) => string | undefined
10
10
  now: number
11
+ authorProfileTo?: (authorUid: string) => string | undefined
11
12
  authorPostsTo?: (authorUid: string) => string | undefined
13
+ authorCommentsTo?: (authorUid: string) => string | undefined
12
14
  }>()
13
15
  const emit = defineEmits<{ select: [post: PostModel] }>()
14
16
  const NuxtLink = resolveComponent('NuxtLink')
@@ -58,7 +60,7 @@ function cover(post: PostModel): string | undefined {
58
60
  {{ post.commentCount }}
59
61
  </span>
60
62
  </div>
61
- <p v-if="post.summary" class="mt-2 line-clamp-2 text-sm text-muted">{{ post.summary }}</p>
63
+ <p v-if="post.summary" class="mt-2 line-clamp-2 whitespace-pre-line text-sm text-muted">{{ post.summary }}</p>
62
64
  </div>
63
65
  </component>
64
66
 
@@ -68,6 +70,8 @@ function cover(post: PostModel): string | undefined {
68
70
  :author-name="post.authorName"
69
71
  :author-photo="post.authorPhoto"
70
72
  :author-posts-to="authorPostsTo"
73
+ :author-profile-to="authorProfileTo"
74
+ :author-comments-to="authorCommentsTo"
71
75
  :show-avatar="false"
72
76
  />
73
77
  <div class="flex shrink-0 items-center gap-2">
@@ -75,18 +79,19 @@ function cover(post: PostModel): string | undefined {
75
79
  <UIcon name="i-lucide-eye" class="size-3" />
76
80
  {{ post.viewCount ?? 0 }}
77
81
  </span>
78
- <UTooltip
79
- :delay-duration="0"
80
- :text="formatTimestampDetails(post.createdAt, post.updatedAt).join('\n')"
81
- :ui="{ content: 'h-auto w-max py-2', text: 'whitespace-pre-line overflow-visible' }"
82
- >
82
+ <UPopover :content="{ side: 'top' }" :ui="{ content: 'h-auto w-max' }">
83
83
  <time
84
84
  :datetime="post.createdAt?.toDate?.().toISOString()"
85
- class="cursor-help"
85
+ class="cursor-pointer"
86
86
  >
87
87
  {{ formatRelativeDate(post.createdAt, now) }}
88
88
  </time>
89
- </UTooltip>
89
+ <template #content>
90
+ <p class="whitespace-pre-line px-3 py-2 text-xs">
91
+ {{ formatTimestampDetails(post.createdAt, post.updatedAt).join('\n') }}
92
+ </p>
93
+ </template>
94
+ </UPopover>
90
95
  </div>
91
96
  </div>
92
97
  </div>
@@ -0,0 +1,93 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from 'vue'
3
+ import { BOARD_USER_ROLES, formatRelativeDate, useMemiBoardAuth, useMemiBoardUserProfile } from 'memi-board/runtime'
4
+ import MemiBoardProfileEditModal from './ProfileEditModal.vue'
5
+
6
+ const props = defineProps<{
7
+ uid: string
8
+ }>()
9
+
10
+ const { profile, profilePending } = useMemiBoardUserProfile(() => props.uid)
11
+ const now = ref(Date.now())
12
+
13
+ const roleLabel = computed(() => BOARD_USER_ROLES.find(item => item.value === profile.value?.role)?.label)
14
+
15
+ const { user } = useMemiBoardAuth()
16
+ const isOwnProfile = computed(() => !!user.value?.uid && user.value.uid === props.uid)
17
+ const editOpen = ref(false)
18
+ </script>
19
+
20
+ <template>
21
+ <div
22
+ v-if="profilePending"
23
+ class="flex items-center gap-3"
24
+ >
25
+ <USkeleton class="size-16 shrink-0 rounded-full" />
26
+ <div class="flex flex-col gap-2">
27
+ <USkeleton class="h-5 w-32" />
28
+ <USkeleton class="h-4 w-48" />
29
+ </div>
30
+ </div>
31
+
32
+ <p
33
+ v-else-if="!profile"
34
+ class="text-sm text-muted"
35
+ >
36
+ 사용자를 찾을 수 없습니다.
37
+ </p>
38
+
39
+ <div
40
+ v-else
41
+ class="flex flex-col gap-3"
42
+ >
43
+ <div class="flex items-center gap-3">
44
+ <UAvatar
45
+ :src="profile.photoURL ?? undefined"
46
+ :alt="profile.displayName ?? '익명'"
47
+ size="xl"
48
+ class="shrink-0"
49
+ />
50
+ <div class="flex min-w-0 flex-1 flex-col gap-0.5">
51
+ <div class="flex min-w-0 items-center gap-2">
52
+ <h2 class="min-w-0 truncate text-lg font-bold text-highlighted">
53
+ {{ profile.displayName ?? '이름 없음' }}
54
+ </h2>
55
+ <UBadge
56
+ v-if="roleLabel && profile.role !== 'user'"
57
+ :label="roleLabel"
58
+ color="primary"
59
+ variant="subtle"
60
+ size="sm"
61
+ />
62
+ </div>
63
+ <p class="text-xs text-muted">
64
+ 가입 {{ formatRelativeDate(profile.joinedAt, now) }} · 최근 방문 {{ formatRelativeDate(profile.lastVisitAt, now) }}
65
+ </p>
66
+ </div>
67
+ <UButton
68
+ v-if="isOwnProfile"
69
+ label="수정"
70
+ icon="i-lucide-pencil"
71
+ color="neutral"
72
+ variant="outline"
73
+ size="sm"
74
+ class="shrink-0"
75
+ @click="editOpen = true"
76
+ />
77
+ </div>
78
+
79
+ <p
80
+ v-if="profile.bio"
81
+ class="whitespace-pre-line text-sm text-toned"
82
+ >
83
+ {{ profile.bio }}
84
+ </p>
85
+ </div>
86
+
87
+ <MemiBoardProfileEditModal
88
+ v-if="isOwnProfile"
89
+ v-model:open="editOpen"
90
+ :uid="uid"
91
+ :profile="profile"
92
+ />
93
+ </template>
@@ -0,0 +1,190 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from 'vue'
3
+ import { serverTimestamp, updateDoc } from 'firebase/firestore'
4
+ import { useFirestore } from 'vuefire'
5
+ import { boardUserDoc, useBoardPathConfig } from 'memi-board/runtime'
6
+ import type { BoardUserModel } from 'memi-board/runtime'
7
+ import { useMemiBoardStorage } from 'memi-board/storage'
8
+ import MemiBoardAvatarCropModal from './AvatarCropModal.vue'
9
+
10
+ const open = defineModel<boolean>('open', { default: false })
11
+
12
+ const props = defineProps<{
13
+ uid: string
14
+ profile: BoardUserModel | null | undefined
15
+ }>()
16
+
17
+ const emit = defineEmits<{ saved: [] }>()
18
+
19
+ const db = useFirestore()
20
+
21
+ const displayName = ref('')
22
+ const bio = ref('')
23
+ const photoURL = ref<string | null>(null)
24
+ const saving = ref(false)
25
+ const error = ref('')
26
+
27
+ // 열릴 때마다 최신 프로필로 폼을 리셋 — 이전에 취소한 입력이 남아있지 않게.
28
+ watch(open, (isOpen) => {
29
+ if (!isOpen) return
30
+ displayName.value = props.profile?.displayName ?? ''
31
+ bio.value = props.profile?.bio ?? ''
32
+ photoURL.value = props.profile?.photoURL ?? null
33
+ error.value = ''
34
+ })
35
+
36
+ const cropOpen = ref(false)
37
+ const cropSrc = ref('')
38
+ const avatarUploading = ref(false)
39
+ const avatarInput = ref<HTMLInputElement | null>(null)
40
+ let pendingAvatarFile: File | null = null
41
+
42
+ const { uploadAvatar } = useMemiBoardStorage()
43
+
44
+ function onPickAvatar(event: Event) {
45
+ const input = event.target as HTMLInputElement
46
+ const file = input.files?.[0]
47
+ input.value = ''
48
+ if (!file) return
49
+ if (!file.type.startsWith('image/')) {
50
+ error.value = '이미지 파일만 선택할 수 있습니다.'
51
+ return
52
+ }
53
+ if (cropSrc.value.startsWith('blob:')) URL.revokeObjectURL(cropSrc.value)
54
+ cropSrc.value = URL.createObjectURL(file)
55
+ cropOpen.value = true
56
+ }
57
+
58
+ function onCropConfirm(file: File) {
59
+ pendingAvatarFile = file
60
+ photoURL.value = URL.createObjectURL(file)
61
+ cropOpen.value = false
62
+ }
63
+
64
+ const avatarPreview = computed(() => photoURL.value ?? undefined)
65
+
66
+ async function onSave() {
67
+ if (saving.value) return
68
+ const trimmedName = displayName.value.trim()
69
+ if (!trimmedName) {
70
+ error.value = '이름을 입력해 주세요.'
71
+ return
72
+ }
73
+ saving.value = true
74
+ error.value = ''
75
+ try {
76
+ let nextPhotoURL = props.profile?.photoURL ?? null
77
+ if (pendingAvatarFile) {
78
+ avatarUploading.value = true
79
+ nextPhotoURL = await uploadAvatar(props.uid, pendingAvatarFile)
80
+ avatarUploading.value = false
81
+ }
82
+
83
+ await updateDoc(boardUserDoc(db, useBoardPathConfig(), props.uid), {
84
+ displayName: trimmedName,
85
+ bio: bio.value.trim() || null,
86
+ photoURL: nextPhotoURL,
87
+ updatedAt: serverTimestamp(),
88
+ })
89
+
90
+ pendingAvatarFile = null
91
+ open.value = false
92
+ emit('saved')
93
+ }
94
+ catch (cause) {
95
+ error.value = cause instanceof Error ? cause.message : '프로필을 저장하지 못했습니다.'
96
+ }
97
+ finally {
98
+ saving.value = false
99
+ avatarUploading.value = false
100
+ }
101
+ }
102
+ </script>
103
+
104
+ <template>
105
+ <UModal
106
+ v-model:open="open"
107
+ title="프로필 수정"
108
+ :ui="{ content: 'sm:max-w-md' }"
109
+ >
110
+ <template #body>
111
+ <div class="flex flex-col gap-4">
112
+ <div class="flex items-center gap-3">
113
+ <UAvatar
114
+ :src="avatarPreview"
115
+ :alt="displayName || '익명'"
116
+ size="xl"
117
+ />
118
+ <UButton
119
+ label="사진 변경"
120
+ icon="i-lucide-camera"
121
+ color="neutral"
122
+ variant="outline"
123
+ size="sm"
124
+ @click="avatarInput?.click()"
125
+ />
126
+ <input
127
+ ref="avatarInput"
128
+ type="file"
129
+ accept="image/*"
130
+ class="hidden"
131
+ @change="onPickAvatar"
132
+ >
133
+ </div>
134
+
135
+ <UFormField label="이름">
136
+ <UInput
137
+ v-model="displayName"
138
+ placeholder="이름"
139
+ :maxlength="40"
140
+ class="w-full"
141
+ />
142
+ </UFormField>
143
+
144
+ <UFormField label="자기소개">
145
+ <UTextarea
146
+ v-model="bio"
147
+ :rows="3"
148
+ :maxlength="200"
149
+ placeholder="나를 소개해 보세요"
150
+ class="w-full"
151
+ />
152
+ </UFormField>
153
+
154
+ <p
155
+ v-if="error"
156
+ class="text-sm text-error"
157
+ >
158
+ {{ error }}
159
+ </p>
160
+ </div>
161
+ </template>
162
+
163
+ <template #footer>
164
+ <div class="flex w-full justify-end gap-2">
165
+ <UButton
166
+ color="neutral"
167
+ variant="ghost"
168
+ :disabled="saving"
169
+ @click="open = false"
170
+ >
171
+ 취소
172
+ </UButton>
173
+ <UButton
174
+ :loading="saving"
175
+ @click="onSave"
176
+ >
177
+ 저장
178
+ </UButton>
179
+ </div>
180
+ </template>
181
+ </UModal>
182
+
183
+ <MemiBoardAvatarCropModal
184
+ v-model:open="cropOpen"
185
+ :src="cropSrc"
186
+ :output-size="256"
187
+ :loading="avatarUploading"
188
+ @confirm="onCropConfirm"
189
+ />
190
+ </template>
@@ -54,7 +54,7 @@ const { users } = useMemiBoardUsers({ enabled: canManageStaffAssignment })
54
54
  // 이 카테고리에 글/댓글을 쓰려면 스태프 역할이 필요할 때만 "허용 스태프" 지정이 의미가 있다.
55
55
  const staffOptions = computed(() => users.value
56
56
  .filter(item => item.role === 'staff')
57
- .map(item => ({ label: item.displayName || item.email || item.id, value: item.id })))
57
+ .map(item => ({ label: item.displayName || item.id, value: item.id })))
58
58
  function needsStaffPicker(category: BoardCategory) {
59
59
  return (category.writeRole === 'staff' || category.commentWriteRole === 'staff')
60
60
  && canManageStaffAssignment.value