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.
@@ -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.1",
3
+ "version": "0.4.2",
4
4
  "type": "module",
5
- "description": "Firebase 기반 게시판 Vue 컴포넌트/composable 패키지 — Nuxt 4 + Nuxt UI 프로젝트에 posts CRUD, 첨부파일, 댓글, 권한, AI 검열을 바로 붙임",
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": ["vue", "nuxt", "firebase", "firestore", "board", "community", "nuxt-ui"],
17
- "sideEffects": false,
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": "vite 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
  }