memi-board 0.3.1 → 0.4.2
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 +31 -28
- package/dist/composables/useMemiBoardSettings.d.ts +10 -4
- package/dist/index.d.ts +3 -9
- package/dist/index.js +216 -828
- package/dist/index.js.map +1 -1
- package/dist/module.d.ts +4 -0
- package/dist/module.mjs +75 -0
- package/dist/module.mjs.map +1 -0
- package/dist/runtime/components/Attachments.vue +173 -0
- package/dist/runtime/components/CommentForm.vue +83 -0
- package/dist/runtime/components/CommentList.vue +64 -0
- package/dist/runtime/components/Detail.vue +156 -0
- package/dist/runtime/components/Editor.vue +287 -0
- package/dist/runtime/components/List.vue +134 -0
- package/dist/runtime/components/SignIn.vue +146 -0
- package/dist/runtime/components/VersionHistory.vue +36 -0
- package/package.json +35 -9
- package/dist/components/Attachments.vue.d.ts +0 -17
- package/dist/components/CommentForm.vue.d.ts +0 -5
- package/dist/components/CommentList.vue.d.ts +0 -5
- package/dist/components/Detail.vue.d.ts +0 -11
- package/dist/components/Editor.vue.d.ts +0 -12
- package/dist/components/List.vue.d.ts +0 -14
- package/dist/components/SignIn.vue.d.ts +0 -2
- package/dist/components/VersionHistory.vue.d.ts +0 -2
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, computed, watch } from 'vue'
|
|
3
|
+
import type { Attachment } from 'memi-board'
|
|
4
|
+
import { useMemiBoardAuth } from 'memi-board'
|
|
5
|
+
import { useMemiBoardPosts } from 'memi-board'
|
|
6
|
+
import { useMemiBoardModeration } from 'memi-board'
|
|
7
|
+
import { useMemiBoardSettings } from 'memi-board'
|
|
8
|
+
import MemiBoardAttachments from './Attachments.vue'
|
|
9
|
+
|
|
10
|
+
const props = defineProps<{
|
|
11
|
+
/** 지정하면 수정 모드, 지정하지 않으면 새 글 작성 모드. */
|
|
12
|
+
postId?: string
|
|
13
|
+
}>()
|
|
14
|
+
|
|
15
|
+
const emit = defineEmits<{ saved: [id: string], cancel: [] }>()
|
|
16
|
+
|
|
17
|
+
const { user, isSignedIn } = useMemiBoardAuth()
|
|
18
|
+
const { getPost, createPost, updatePost } = useMemiBoardPosts()
|
|
19
|
+
const { checkText } = useMemiBoardModeration()
|
|
20
|
+
const { categories, ensureSettings, addCategory } = useMemiBoardSettings()
|
|
21
|
+
|
|
22
|
+
const title = ref('')
|
|
23
|
+
const content = ref('')
|
|
24
|
+
const tagsInput = ref('')
|
|
25
|
+
/** boardSettings.categories 의 id */
|
|
26
|
+
const category = ref<string | undefined>(undefined)
|
|
27
|
+
const attachments = ref<Attachment[]>([])
|
|
28
|
+
|
|
29
|
+
const categoryItems = computed(() =>
|
|
30
|
+
categories.value.map(c => ({ label: c.label, value: c.id })),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
const isEdit = computed(() => Boolean(props.postId))
|
|
34
|
+
const loading = ref(false)
|
|
35
|
+
const saving = ref(false)
|
|
36
|
+
const error = ref('')
|
|
37
|
+
|
|
38
|
+
const showAddCategory = ref(false)
|
|
39
|
+
const newCategoryLabel = ref('')
|
|
40
|
+
const addingCategory = ref(false)
|
|
41
|
+
|
|
42
|
+
/** 새 글은 아직 slug가 없으므로 첨부파일 Storage 경로용 임시 네임스페이스를 쓴다. */
|
|
43
|
+
const attachmentNamespace = ref(props.postId ?? `new-${Date.now()}`)
|
|
44
|
+
|
|
45
|
+
async function loadPost(id: string) {
|
|
46
|
+
loading.value = true
|
|
47
|
+
error.value = ''
|
|
48
|
+
try {
|
|
49
|
+
await ensureSettings().catch(() => {})
|
|
50
|
+
const post = await getPost(id)
|
|
51
|
+
if (!post) {
|
|
52
|
+
error.value = '게시글을 찾을 수 없습니다.'
|
|
53
|
+
title.value = ''
|
|
54
|
+
content.value = ''
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
title.value = post.title
|
|
58
|
+
content.value = post.content
|
|
59
|
+
tagsInput.value = (post.tags ?? []).join(', ')
|
|
60
|
+
// 설정에 없는 예전 카테고리 id 여도 선택값은 유지 (목록에 없으면 USelect 가 비울 수 있음)
|
|
61
|
+
category.value = post.category
|
|
62
|
+
attachments.value = post.attachments ?? []
|
|
63
|
+
attachmentNamespace.value = id
|
|
64
|
+
}
|
|
65
|
+
catch (e) {
|
|
66
|
+
const msg = (e as Error).message || String(e)
|
|
67
|
+
error.value = msg.includes('permission')
|
|
68
|
+
? '글을 불러올 권한이 없습니다. 로그인 상태를 확인해 주세요.'
|
|
69
|
+
: `글을 불러오지 못했습니다: ${msg}`
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
loading.value = false
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
watch(
|
|
77
|
+
() => props.postId,
|
|
78
|
+
(id) => {
|
|
79
|
+
if (id) void loadPost(id)
|
|
80
|
+
else {
|
|
81
|
+
loading.value = false
|
|
82
|
+
attachmentNamespace.value = `new-${Date.now()}`
|
|
83
|
+
void ensureSettings().catch(() => {})
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
{ immediate: true },
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
async function handleAddCategory() {
|
|
90
|
+
error.value = ''
|
|
91
|
+
if (!isSignedIn.value) {
|
|
92
|
+
error.value = '카테고리 추가에는 로그인이 필요합니다.'
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
addingCategory.value = true
|
|
96
|
+
try {
|
|
97
|
+
const id = await addCategory(newCategoryLabel.value)
|
|
98
|
+
category.value = id
|
|
99
|
+
newCategoryLabel.value = ''
|
|
100
|
+
showAddCategory.value = false
|
|
101
|
+
}
|
|
102
|
+
catch (e) {
|
|
103
|
+
error.value = (e as Error).message || String(e)
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
addingCategory.value = false
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function friendlyWriteError(e: unknown): string {
|
|
111
|
+
const msg = e instanceof Error ? e.message : String(e)
|
|
112
|
+
if (msg.includes('permission-denied') || msg.includes('Permission denied')) {
|
|
113
|
+
return '수정 권한이 없습니다. 본인이 작성한 글인지, 로그인이 유지되는지 확인해 주세요.'
|
|
114
|
+
}
|
|
115
|
+
return msg
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function handleSubmit() {
|
|
119
|
+
error.value = ''
|
|
120
|
+
if (!title.value.trim() || !content.value.trim()) {
|
|
121
|
+
error.value = '제목과 내용을 입력해 주세요.'
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
if (!user.value) {
|
|
125
|
+
error.value = '로그인이 필요합니다.'
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
if (!category.value) {
|
|
129
|
+
error.value = '카테고리를 선택해 주세요.'
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
// 설정에 있는 id 만 허용 (임의 문자열 저장 방지)
|
|
133
|
+
if (!categories.value.some(c => c.id === category.value)) {
|
|
134
|
+
error.value = '목록에 있는 카테고리를 선택해 주세요. 없으면 아래 + 로 추가할 수 있습니다.'
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
saving.value = true
|
|
139
|
+
try {
|
|
140
|
+
const moderation = await checkText(`${title.value}\n${content.value}`)
|
|
141
|
+
if (moderation.flagged) {
|
|
142
|
+
error.value = moderation.reason || '게시할 수 없는 내용이 포함되어 있습니다.'
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const tags = tagsInput.value.split(',').map(t => t.trim()).filter(Boolean)
|
|
147
|
+
const payload = {
|
|
148
|
+
title: title.value.trim(),
|
|
149
|
+
content: content.value,
|
|
150
|
+
tags,
|
|
151
|
+
category: category.value,
|
|
152
|
+
attachments: attachments.value,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (props.postId) {
|
|
156
|
+
await updatePost(props.postId, payload)
|
|
157
|
+
emit('saved', props.postId)
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
const id = await createPost({
|
|
161
|
+
...payload,
|
|
162
|
+
authorUid: user.value.uid,
|
|
163
|
+
authorName: user.value.displayName,
|
|
164
|
+
authorPhoto: user.value.photoURL,
|
|
165
|
+
})
|
|
166
|
+
emit('saved', id)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
catch (e) {
|
|
170
|
+
error.value = friendlyWriteError(e)
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
saving.value = false
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
</script>
|
|
177
|
+
|
|
178
|
+
<template>
|
|
179
|
+
<div
|
|
180
|
+
v-if="loading"
|
|
181
|
+
class="flex flex-col gap-3"
|
|
182
|
+
>
|
|
183
|
+
<USkeleton class="h-10 w-full" />
|
|
184
|
+
<USkeleton class="h-40 w-full" />
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
<form
|
|
188
|
+
v-else
|
|
189
|
+
class="flex flex-col gap-4"
|
|
190
|
+
@submit.prevent="handleSubmit"
|
|
191
|
+
>
|
|
192
|
+
<div class="flex flex-col gap-2">
|
|
193
|
+
<div class="flex items-center gap-2 flex-wrap">
|
|
194
|
+
<USelect
|
|
195
|
+
v-model="category"
|
|
196
|
+
:items="categoryItems"
|
|
197
|
+
placeholder="카테고리 선택"
|
|
198
|
+
class="w-48"
|
|
199
|
+
/>
|
|
200
|
+
<UButton
|
|
201
|
+
type="button"
|
|
202
|
+
v-if="isSignedIn"
|
|
203
|
+
variant="outline"
|
|
204
|
+
color="neutral"
|
|
205
|
+
size="sm"
|
|
206
|
+
icon="i-lucide-plus"
|
|
207
|
+
label="카테고리 추가"
|
|
208
|
+
@click="showAddCategory = !showAddCategory"
|
|
209
|
+
/>
|
|
210
|
+
</div>
|
|
211
|
+
<p class="text-xs text-muted">
|
|
212
|
+
카테고리는 게시판 설정(boardSettings)에 있는 목록에서 고릅니다.
|
|
213
|
+
</p>
|
|
214
|
+
|
|
215
|
+
<div
|
|
216
|
+
v-if="showAddCategory"
|
|
217
|
+
class="flex items-center gap-2"
|
|
218
|
+
>
|
|
219
|
+
<UInput
|
|
220
|
+
v-model="newCategoryLabel"
|
|
221
|
+
placeholder="새 카테고리 이름"
|
|
222
|
+
class="w-48"
|
|
223
|
+
@keydown.enter.prevent="handleAddCategory"
|
|
224
|
+
/>
|
|
225
|
+
<UButton
|
|
226
|
+
type="button"
|
|
227
|
+
size="sm"
|
|
228
|
+
label="추가"
|
|
229
|
+
:loading="addingCategory"
|
|
230
|
+
@click="handleAddCategory"
|
|
231
|
+
/>
|
|
232
|
+
<UButton
|
|
233
|
+
type="button"
|
|
234
|
+
size="sm"
|
|
235
|
+
variant="ghost"
|
|
236
|
+
color="neutral"
|
|
237
|
+
label="취소"
|
|
238
|
+
@click="showAddCategory = false; newCategoryLabel = ''"
|
|
239
|
+
/>
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
|
|
243
|
+
<UInput
|
|
244
|
+
v-model="title"
|
|
245
|
+
placeholder="제목"
|
|
246
|
+
size="lg"
|
|
247
|
+
required
|
|
248
|
+
/>
|
|
249
|
+
<UTextarea
|
|
250
|
+
v-model="content"
|
|
251
|
+
placeholder="내용을 입력하세요"
|
|
252
|
+
:rows="12"
|
|
253
|
+
required
|
|
254
|
+
/>
|
|
255
|
+
<UInput
|
|
256
|
+
v-model="tagsInput"
|
|
257
|
+
placeholder="태그 (쉼표로 구분)"
|
|
258
|
+
/>
|
|
259
|
+
|
|
260
|
+
<MemiBoardAttachments
|
|
261
|
+
v-model="attachments"
|
|
262
|
+
:post-id="attachmentNamespace"
|
|
263
|
+
editable
|
|
264
|
+
/>
|
|
265
|
+
|
|
266
|
+
<p
|
|
267
|
+
v-if="error"
|
|
268
|
+
class="text-sm text-error"
|
|
269
|
+
>
|
|
270
|
+
{{ error }}
|
|
271
|
+
</p>
|
|
272
|
+
|
|
273
|
+
<div class="flex gap-2">
|
|
274
|
+
<UButton
|
|
275
|
+
type="submit"
|
|
276
|
+
:loading="saving"
|
|
277
|
+
:label="isEdit ? '수정 완료' : '게시하기'"
|
|
278
|
+
/>
|
|
279
|
+
<UButton
|
|
280
|
+
variant="ghost"
|
|
281
|
+
color="neutral"
|
|
282
|
+
label="취소"
|
|
283
|
+
@click="emit('cancel')"
|
|
284
|
+
/>
|
|
285
|
+
</div>
|
|
286
|
+
</form>
|
|
287
|
+
</template>
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, onMounted } from 'vue'
|
|
3
|
+
import { RouterLink } from 'vue-router'
|
|
4
|
+
import type { DocumentData, QueryDocumentSnapshot } from 'firebase/firestore'
|
|
5
|
+
import { useMemiBoardPosts } from 'memi-board'
|
|
6
|
+
import { useMemiBoardSettings } from 'memi-board'
|
|
7
|
+
import type { PostModel } from 'memi-board'
|
|
8
|
+
import { formatDate } from 'memi-board'
|
|
9
|
+
|
|
10
|
+
const props = withDefaults(defineProps<{
|
|
11
|
+
pageSize?: number
|
|
12
|
+
/** 지정하면 각 글이 `${linkBase}/${id}`로 이동하는 링크가 된다. 지정하지 않으면 select 이벤트만 emit. */
|
|
13
|
+
linkBase?: string
|
|
14
|
+
}>(), {
|
|
15
|
+
pageSize: 20,
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const emit = defineEmits<{ select: [post: PostModel] }>()
|
|
19
|
+
|
|
20
|
+
const { getPosts } = useMemiBoardPosts()
|
|
21
|
+
const { categoryLabel } = useMemiBoardSettings()
|
|
22
|
+
|
|
23
|
+
const posts = ref<PostModel[]>([])
|
|
24
|
+
const cursor = ref<QueryDocumentSnapshot<DocumentData> | undefined>(undefined)
|
|
25
|
+
const hasMore = ref(false)
|
|
26
|
+
const loading = ref(false)
|
|
27
|
+
const initialLoading = ref(true)
|
|
28
|
+
const loadError = ref('')
|
|
29
|
+
|
|
30
|
+
async function loadMore() {
|
|
31
|
+
loading.value = true
|
|
32
|
+
loadError.value = ''
|
|
33
|
+
try {
|
|
34
|
+
const result = await getPosts({ pageSize: props.pageSize, cursor: cursor.value })
|
|
35
|
+
posts.value.push(...result.posts)
|
|
36
|
+
cursor.value = result.cursor
|
|
37
|
+
hasMore.value = result.hasMore
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
loadError.value = (e as Error).message || String(e)
|
|
41
|
+
console.error('[memi-board] getPosts failed', e)
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
loading.value = false
|
|
45
|
+
initialLoading.value = false
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
onMounted(loadMore)
|
|
50
|
+
|
|
51
|
+
function handleClick(post: PostModel) {
|
|
52
|
+
if (!props.linkBase) emit('select', post)
|
|
53
|
+
}
|
|
54
|
+
</script>
|
|
55
|
+
|
|
56
|
+
<template>
|
|
57
|
+
<div class="flex flex-col gap-2">
|
|
58
|
+
<template v-if="initialLoading">
|
|
59
|
+
<USkeleton
|
|
60
|
+
v-for="i in 3"
|
|
61
|
+
:key="i"
|
|
62
|
+
class="h-20 w-full"
|
|
63
|
+
/>
|
|
64
|
+
</template>
|
|
65
|
+
|
|
66
|
+
<p
|
|
67
|
+
v-else-if="loadError"
|
|
68
|
+
class="text-sm text-error text-center py-8"
|
|
69
|
+
>
|
|
70
|
+
목록을 불러오지 못했습니다: {{ loadError }}
|
|
71
|
+
</p>
|
|
72
|
+
|
|
73
|
+
<p
|
|
74
|
+
v-else-if="!posts.length"
|
|
75
|
+
class="text-sm text-muted text-center py-8"
|
|
76
|
+
>
|
|
77
|
+
아직 게시글이 없습니다.
|
|
78
|
+
</p>
|
|
79
|
+
|
|
80
|
+
<component
|
|
81
|
+
:is="linkBase ? RouterLink : 'button'"
|
|
82
|
+
v-for="post in posts"
|
|
83
|
+
:key="post.id"
|
|
84
|
+
:to="linkBase ? `${linkBase}/${post.id}` : undefined"
|
|
85
|
+
class="text-left"
|
|
86
|
+
@click="handleClick(post)"
|
|
87
|
+
>
|
|
88
|
+
<UCard class="hover:bg-elevated/50 transition-colors">
|
|
89
|
+
<div class="flex items-center justify-between gap-4">
|
|
90
|
+
<div class="flex items-center gap-2 min-w-0">
|
|
91
|
+
<UBadge
|
|
92
|
+
v-if="post.category"
|
|
93
|
+
:label="categoryLabel(post.category)"
|
|
94
|
+
variant="subtle"
|
|
95
|
+
size="sm"
|
|
96
|
+
/>
|
|
97
|
+
<h3 class="font-medium truncate">
|
|
98
|
+
{{ post.title }}
|
|
99
|
+
</h3>
|
|
100
|
+
</div>
|
|
101
|
+
<span class="text-xs text-muted shrink-0">{{ formatDate(post.createdAt) }}</span>
|
|
102
|
+
</div>
|
|
103
|
+
<div class="flex items-center gap-3 text-xs text-muted mt-2">
|
|
104
|
+
<span>{{ post.authorName ?? '익명' }}</span>
|
|
105
|
+
<span class="flex items-center gap-1">
|
|
106
|
+
<UIcon
|
|
107
|
+
name="i-lucide-message-circle"
|
|
108
|
+
class="size-3"
|
|
109
|
+
/>{{ post.commentCount }}
|
|
110
|
+
</span>
|
|
111
|
+
<span
|
|
112
|
+
v-if="post.attachments?.length"
|
|
113
|
+
class="flex items-center gap-1"
|
|
114
|
+
>
|
|
115
|
+
<UIcon
|
|
116
|
+
name="i-lucide-paperclip"
|
|
117
|
+
class="size-3"
|
|
118
|
+
/>{{ post.attachments.length }}
|
|
119
|
+
</span>
|
|
120
|
+
</div>
|
|
121
|
+
</UCard>
|
|
122
|
+
</component>
|
|
123
|
+
|
|
124
|
+
<UButton
|
|
125
|
+
v-if="hasMore"
|
|
126
|
+
variant="outline"
|
|
127
|
+
color="neutral"
|
|
128
|
+
label="더 보기"
|
|
129
|
+
block
|
|
130
|
+
:loading="loading"
|
|
131
|
+
@click="loadMore"
|
|
132
|
+
/>
|
|
133
|
+
</div>
|
|
134
|
+
</template>
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, computed } from 'vue'
|
|
3
|
+
import { useMemiBoardConfig } from 'memi-board'
|
|
4
|
+
import { useMemiBoardAuth } from 'memi-board'
|
|
5
|
+
|
|
6
|
+
const config = useMemiBoardConfig()
|
|
7
|
+
const providers = computed(() => config.auth?.providers ?? ['google', 'apple'])
|
|
8
|
+
const hasOAuthProvider = computed(() => providers.value.includes('google') || providers.value.includes('apple'))
|
|
9
|
+
|
|
10
|
+
const { signInWithGoogle, signInWithApple, signInWithEmail, signUpWithEmail } = useMemiBoardAuth()
|
|
11
|
+
|
|
12
|
+
const mode = ref<'signin' | 'signup'>('signin')
|
|
13
|
+
const email = ref('')
|
|
14
|
+
const password = ref('')
|
|
15
|
+
const displayName = ref('')
|
|
16
|
+
const loading = ref(false)
|
|
17
|
+
const error = ref('')
|
|
18
|
+
|
|
19
|
+
async function handleGoogle() {
|
|
20
|
+
loading.value = true
|
|
21
|
+
error.value = ''
|
|
22
|
+
try {
|
|
23
|
+
await signInWithGoogle()
|
|
24
|
+
}
|
|
25
|
+
catch (e) {
|
|
26
|
+
error.value = (e as Error).message
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
loading.value = false
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function handleApple() {
|
|
34
|
+
loading.value = true
|
|
35
|
+
error.value = ''
|
|
36
|
+
try {
|
|
37
|
+
await signInWithApple()
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
error.value = (e as Error).message
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
loading.value = false
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function handleEmailSubmit() {
|
|
48
|
+
loading.value = true
|
|
49
|
+
error.value = ''
|
|
50
|
+
try {
|
|
51
|
+
if (mode.value === 'signin') {
|
|
52
|
+
await signInWithEmail(email.value, password.value)
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
await signUpWithEmail(email.value, password.value, displayName.value || undefined)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
catch (e) {
|
|
59
|
+
error.value = (e as Error).message
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
loading.value = false
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
</script>
|
|
66
|
+
|
|
67
|
+
<template>
|
|
68
|
+
<div class="flex flex-col gap-4 max-w-sm">
|
|
69
|
+
<UButton
|
|
70
|
+
v-if="providers.includes('google')"
|
|
71
|
+
icon="i-simple-icons-google"
|
|
72
|
+
color="neutral"
|
|
73
|
+
variant="outline"
|
|
74
|
+
label="Google로 로그인"
|
|
75
|
+
block
|
|
76
|
+
:loading="loading"
|
|
77
|
+
@click="handleGoogle"
|
|
78
|
+
/>
|
|
79
|
+
|
|
80
|
+
<UButton
|
|
81
|
+
v-if="providers.includes('apple')"
|
|
82
|
+
icon="i-simple-icons-apple"
|
|
83
|
+
color="neutral"
|
|
84
|
+
variant="outline"
|
|
85
|
+
label="Apple로 로그인"
|
|
86
|
+
block
|
|
87
|
+
:loading="loading"
|
|
88
|
+
@click="handleApple"
|
|
89
|
+
/>
|
|
90
|
+
|
|
91
|
+
<template v-if="providers.includes('emailPassword')">
|
|
92
|
+
<div
|
|
93
|
+
v-if="hasOAuthProvider"
|
|
94
|
+
class="flex items-center gap-2 text-xs text-muted"
|
|
95
|
+
>
|
|
96
|
+
<div class="flex-1 border-t border-default" />
|
|
97
|
+
또는
|
|
98
|
+
<div class="flex-1 border-t border-default" />
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
<form
|
|
102
|
+
class="flex flex-col gap-3"
|
|
103
|
+
@submit.prevent="handleEmailSubmit"
|
|
104
|
+
>
|
|
105
|
+
<UInput
|
|
106
|
+
v-if="mode === 'signup'"
|
|
107
|
+
v-model="displayName"
|
|
108
|
+
placeholder="이름"
|
|
109
|
+
/>
|
|
110
|
+
<UInput
|
|
111
|
+
v-model="email"
|
|
112
|
+
type="email"
|
|
113
|
+
placeholder="이메일"
|
|
114
|
+
required
|
|
115
|
+
/>
|
|
116
|
+
<UInput
|
|
117
|
+
v-model="password"
|
|
118
|
+
type="password"
|
|
119
|
+
placeholder="비밀번호"
|
|
120
|
+
required
|
|
121
|
+
/>
|
|
122
|
+
<UButton
|
|
123
|
+
type="submit"
|
|
124
|
+
block
|
|
125
|
+
:loading="loading"
|
|
126
|
+
:label="mode === 'signin' ? '로그인' : '회원가입'"
|
|
127
|
+
/>
|
|
128
|
+
</form>
|
|
129
|
+
|
|
130
|
+
<UButton
|
|
131
|
+
variant="link"
|
|
132
|
+
size="sm"
|
|
133
|
+
color="neutral"
|
|
134
|
+
:label="mode === 'signin' ? '계정이 없으신가요? 회원가입' : '이미 계정이 있으신가요? 로그인'"
|
|
135
|
+
@click="mode = mode === 'signin' ? 'signup' : 'signin'"
|
|
136
|
+
/>
|
|
137
|
+
</template>
|
|
138
|
+
|
|
139
|
+
<p
|
|
140
|
+
v-if="error"
|
|
141
|
+
class="text-sm text-error"
|
|
142
|
+
>
|
|
143
|
+
{{ error }}
|
|
144
|
+
</p>
|
|
145
|
+
</div>
|
|
146
|
+
</template>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { versionHistory } from 'memi-board'
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
<template>
|
|
6
|
+
<div class="flex flex-col gap-6">
|
|
7
|
+
<div
|
|
8
|
+
v-for="entry in versionHistory"
|
|
9
|
+
:key="entry.version"
|
|
10
|
+
class="flex flex-col gap-2"
|
|
11
|
+
>
|
|
12
|
+
<div class="flex items-center gap-2">
|
|
13
|
+
<UBadge
|
|
14
|
+
:label="`v${entry.version}`"
|
|
15
|
+
color="primary"
|
|
16
|
+
variant="subtle"
|
|
17
|
+
/>
|
|
18
|
+
<span class="text-sm text-muted">{{ entry.date }}</span>
|
|
19
|
+
</div>
|
|
20
|
+
<ul class="list-disc list-inside text-sm leading-relaxed space-y-1">
|
|
21
|
+
<li
|
|
22
|
+
v-for="item in entry.highlights"
|
|
23
|
+
:key="item"
|
|
24
|
+
>
|
|
25
|
+
{{ item }}
|
|
26
|
+
</li>
|
|
27
|
+
</ul>
|
|
28
|
+
<p
|
|
29
|
+
v-if="entry.note"
|
|
30
|
+
class="text-xs text-muted"
|
|
31
|
+
>
|
|
32
|
+
{{ entry.note }}
|
|
33
|
+
</p>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
</template>
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memi-board",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Firebase
|
|
5
|
+
"description": "Firebase 게시판 — core composable + thin Nuxt module(UI SFC). Nuxt 4 + Nuxt UI 호스트에 붙임",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "fkkmemi",
|
|
8
8
|
"repository": {
|
|
@@ -13,8 +13,19 @@
|
|
|
13
13
|
"url": "https://github.com/fkkmemi/memi-board/issues"
|
|
14
14
|
},
|
|
15
15
|
"homepage": "https://github.com/fkkmemi/memi-board#readme",
|
|
16
|
-
"keywords": [
|
|
17
|
-
|
|
16
|
+
"keywords": [
|
|
17
|
+
"vue",
|
|
18
|
+
"nuxt",
|
|
19
|
+
"firebase",
|
|
20
|
+
"firestore",
|
|
21
|
+
"board",
|
|
22
|
+
"community",
|
|
23
|
+
"nuxt-ui"
|
|
24
|
+
],
|
|
25
|
+
"sideEffects": [
|
|
26
|
+
"**/*.vue",
|
|
27
|
+
"**/*.css"
|
|
28
|
+
],
|
|
18
29
|
"publishConfig": {
|
|
19
30
|
"access": "public"
|
|
20
31
|
},
|
|
@@ -22,6 +33,10 @@
|
|
|
22
33
|
".": {
|
|
23
34
|
"types": "./dist/index.d.ts",
|
|
24
35
|
"import": "./dist/index.js"
|
|
36
|
+
},
|
|
37
|
+
"./nuxt": {
|
|
38
|
+
"types": "./dist/module.d.ts",
|
|
39
|
+
"import": "./dist/module.mjs"
|
|
25
40
|
}
|
|
26
41
|
},
|
|
27
42
|
"main": "./dist/index.js",
|
|
@@ -31,23 +46,31 @@
|
|
|
31
46
|
],
|
|
32
47
|
"scripts": {
|
|
33
48
|
"prepack": "pnpm build",
|
|
34
|
-
"build": "
|
|
49
|
+
"build": "node scripts/build.mjs",
|
|
35
50
|
"build:watch": "vite build --watch",
|
|
36
|
-
"dev": "nuxi dev playground",
|
|
37
|
-
"dev:build": "nuxi build playground",
|
|
38
|
-
"dev:prepare": "nuxi prepare playground",
|
|
51
|
+
"dev": "pnpm build && nuxi dev playground",
|
|
52
|
+
"dev:build": "pnpm build && nuxi build playground",
|
|
53
|
+
"dev:prepare": "pnpm build && nuxi prepare playground",
|
|
39
54
|
"typecheck": "vue-tsc --noEmit"
|
|
40
55
|
},
|
|
41
56
|
"peerDependencies": {
|
|
42
57
|
"@nuxt/ui": "^4.0.0",
|
|
43
58
|
"firebase": "^12.0.0",
|
|
59
|
+
"nuxt": ">=3.0.0 || >=4.0.0",
|
|
44
60
|
"vue": "^3.5.0",
|
|
45
61
|
"vue-router": "^4.0.0 || ^5.0.0",
|
|
46
62
|
"vuefire": "^3.2.3"
|
|
47
63
|
},
|
|
64
|
+
"peerDependenciesMeta": {
|
|
65
|
+
"nuxt": {
|
|
66
|
+
"optional": true
|
|
67
|
+
},
|
|
68
|
+
"@nuxt/ui": {
|
|
69
|
+
"optional": true
|
|
70
|
+
}
|
|
71
|
+
},
|
|
48
72
|
"devDependencies": {
|
|
49
73
|
"@nuxt/ui": "^4.9.0",
|
|
50
|
-
"@vitejs/plugin-vue": "^6.0.8",
|
|
51
74
|
"firebase": "^12.15.0",
|
|
52
75
|
"nuxt": "^4.5.1",
|
|
53
76
|
"nuxt-vuefire": "^1.1.2",
|
|
@@ -67,5 +90,8 @@
|
|
|
67
90
|
"protobufjs",
|
|
68
91
|
"vue-demi"
|
|
69
92
|
]
|
|
93
|
+
},
|
|
94
|
+
"dependencies": {
|
|
95
|
+
"@nuxt/kit": "^4.5.1"
|
|
70
96
|
}
|
|
71
97
|
}
|