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.
- package/README.md +26 -40
- package/dist/composables/useMemiBoardAuthorMemo.d.ts +19 -0
- package/dist/composables/useMemiBoardComments.d.ts +6 -1
- package/dist/composables/useMemiBoardSettings.d.ts +2 -2
- package/dist/composables/useMemiBoardStorage.d.ts +3 -2
- package/dist/composables/useMemiBoardUserComments.d.ts +19 -0
- package/dist/composables/useMemiBoardUserPosts.d.ts +4 -2
- package/dist/composables/useMemiBoardUserProfile.d.ts +10 -0
- package/dist/composables/useMemiBoardUsers.d.ts +5 -4
- package/dist/composables/useMemiBoardViews.d.ts +1 -1
- package/dist/config.d.ts +8 -2
- package/dist/{heic2any-BXb_U1zK.js → heic2any-BHVEj2CD.js} +2 -2
- package/dist/{heic2any-BXb_U1zK.js.map → heic2any-BHVEj2CD.js.map} +1 -1
- package/dist/index.d.ts +5 -5
- package/dist/index.js +870 -906
- package/dist/index.js.map +1 -1
- package/dist/module.mjs +35 -29
- package/dist/module.mjs.map +1 -1
- package/dist/runtime/components/Attachments.vue +2 -3
- package/dist/runtime/components/AuthorMenu.vue +129 -21
- package/dist/runtime/components/AvatarCropModal.vue +108 -0
- package/dist/runtime/components/CategorySettings.vue +1 -1
- package/dist/runtime/components/CommentItem.vue +8 -7
- package/dist/runtime/components/Detail.vue +14 -7
- package/dist/runtime/components/Editor.vue +8 -4
- package/dist/runtime/components/List.vue +6 -0
- package/dist/runtime/components/ListDefault.vue +32 -29
- package/dist/runtime/components/ListDense.vue +86 -97
- package/dist/runtime/components/ListVideo.vue +13 -8
- package/dist/runtime/components/Profile.vue +93 -0
- package/dist/runtime/components/ProfileEditModal.vue +190 -0
- package/dist/runtime/components/Settings.vue +1 -1
- package/dist/runtime/components/UserCommentList.vue +109 -0
- package/dist/runtime/components/UserPostList.vue +10 -1
- package/dist/runtime/components/Users.vue +1 -1
- package/dist/slugify-CDMIDDCN.js +122 -0
- package/dist/slugify-CDMIDDCN.js.map +1 -0
- package/dist/storage.d.ts +9 -0
- package/dist/storage.js +148 -0
- package/dist/storage.js.map +1 -0
- package/dist/types.d.ts +41 -10
- package/dist/utils/boardPaths.d.ts +30 -15
- package/docs/firebase-setup.md +3 -4
- package/docs/firestore.indexes.json.example +136 -0
- package/docs/firestore.rules.example +128 -72
- package/docs/storage.rules.example +22 -12
- package/package.json +7 -2
- 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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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-[
|
|
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
|
-
:
|
|
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
|
-
<
|
|
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-
|
|
105
|
+
class="cursor-pointer text-xs leading-snug text-muted"
|
|
114
106
|
>
|
|
115
107
|
{{ formatRelativeDate(post.createdAt, now) }}
|
|
116
108
|
</time>
|
|
117
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
46
|
-
|
|
60
|
+
<img
|
|
61
|
+
:src="thumb(post)"
|
|
62
|
+
:alt="post.title?.trim() || post.summary || '사진'"
|
|
63
|
+
class="size-full object-cover"
|
|
47
64
|
>
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
85
|
-
<
|
|
86
|
-
<
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
>
|
|
105
|
-
{{
|
|
106
|
-
</
|
|
107
|
-
</
|
|
108
|
-
|
|
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
|
-
|
|
111
|
-
{{ post.viewCount ?? 0 }}
|
|
107
|
+
추천 {{ post.likeCount }}
|
|
112
108
|
</span>
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
<
|
|
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-
|
|
85
|
+
class="cursor-pointer"
|
|
86
86
|
>
|
|
87
87
|
{{ formatRelativeDate(post.createdAt, now) }}
|
|
88
88
|
</time>
|
|
89
|
-
|
|
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.
|
|
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
|