memi-board 0.4.13 → 0.5.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.
@@ -0,0 +1,238 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from 'vue'
3
+ import { slugify, useMemiBoardAuth, useMemiBoardSettings } from 'memi-board'
4
+ import type { BoardCategory, BoardListView } from 'memi-board'
5
+
6
+ const props = withDefaults(defineProps<{
7
+ /** 호스트 자체 관리자 권한도 허용할 때 true. Firebase rules 권한은 호스트가 별도로 맞춰야 한다. */
8
+ authorized?: boolean
9
+ /** 카테고리 페이지 기본 경로. 기본 `/board` → `/board/{id}` */
10
+ categoryBase?: string
11
+ /** 호스트 서버에서 게시판 전체 데이터를 삭제하는 관리자 전용 작업. */
12
+ deleteAll?: () => Promise<void>
13
+ }>(), {
14
+ authorized: false,
15
+ categoryBase: '/board',
16
+ })
17
+
18
+ const emit = defineEmits<{
19
+ saved: [categories: BoardCategory[]]
20
+ }>()
21
+
22
+ const listViewOptions: Array<{ label: string, value: BoardListView }> = [
23
+ { label: '일반', value: 'default' },
24
+ { label: '이미지', value: 'image' },
25
+ { label: '영상', value: 'video' },
26
+ ]
27
+
28
+ const { isAdmin, rolePending } = useMemiBoardAuth()
29
+ const { categories, settingsPending, saveCategory, saveCategories, deleteCategory } = useMemiBoardSettings()
30
+ const canManage = computed(() => isAdmin.value || props.authorized)
31
+ const pending = computed(() => rolePending.value || settingsPending.value)
32
+
33
+ const draft = ref<BoardCategory[]>([])
34
+ const newLabel = ref('')
35
+ const savingId = ref<string | null>(null)
36
+ const savedId = ref<string | null>(null)
37
+ const ordering = ref(false)
38
+ const error = ref('')
39
+ const deleteConfirm = ref('')
40
+ const deletingAll = ref(false)
41
+ const deleteDone = ref(false)
42
+
43
+ watch([categories, settingsPending], ([list, loading]) => {
44
+ if (!loading && draft.value.length === 0) {
45
+ draft.value = list.map(category => ({
46
+ ...category,
47
+ listView: category.listView ?? 'default',
48
+ }))
49
+ }
50
+ }, { immediate: true })
51
+
52
+ function addCategory() {
53
+ const label = newLabel.value.trim()
54
+ if (!label) return
55
+ const base = slugify(label) || `board-${Date.now()}`
56
+ let id = base
57
+ let suffix = 2
58
+ while (draft.value.some(category => category.id === id)) id = `${base}-${suffix++}`
59
+ draft.value.push({ id, label, listView: 'default' })
60
+ newLabel.value = ''
61
+ }
62
+
63
+ async function moveCategory(index: number, offset: -1 | 1) {
64
+ const target = index + offset
65
+ if (target < 0 || target >= draft.value.length) return
66
+ const next = [...draft.value]
67
+ const [item] = next.splice(index, 1)
68
+ next.splice(target, 0, item!)
69
+ draft.value = next.map((category, order) => ({ ...category, order }))
70
+ ordering.value = true
71
+ error.value = ''
72
+ try {
73
+ await saveCategories(draft.value)
74
+ }
75
+ catch (cause) {
76
+ error.value = cause instanceof Error ? cause.message : '카테고리 순서를 저장하지 못했습니다.'
77
+ }
78
+ finally {
79
+ ordering.value = false
80
+ }
81
+ }
82
+
83
+ async function removeCategory(index: number) {
84
+ const category = draft.value[index]
85
+ if (!category || !window.confirm(`‘${category.label}’ 카테고리를 삭제하시겠습니까?`)) return
86
+ error.value = ''
87
+ try {
88
+ await deleteCategory(category.id)
89
+ draft.value.splice(index, 1)
90
+ }
91
+ catch (cause) {
92
+ error.value = cause instanceof Error ? cause.message : '카테고리를 삭제하지 못했습니다.'
93
+ }
94
+ }
95
+
96
+ function categoryTo(id: string) {
97
+ return `${props.categoryBase.replace(/\/$/, '')}/${encodeURIComponent(id)}`
98
+ }
99
+
100
+ async function save(category: BoardCategory, index: number) {
101
+ error.value = ''
102
+ savedId.value = null
103
+ savingId.value = category.id
104
+ try {
105
+ await saveCategory(category, index)
106
+ category.order = index
107
+ savedId.value = category.id
108
+ emit('saved', draft.value)
109
+ }
110
+ catch (cause) {
111
+ error.value = cause instanceof Error ? cause.message : '게시판 설정을 저장하지 못했습니다.'
112
+ }
113
+ finally {
114
+ savingId.value = null
115
+ }
116
+ }
117
+
118
+ async function runDeleteAll() {
119
+ if (!props.deleteAll || deleteConfirm.value !== '게시판 데이터 삭제') return
120
+ deletingAll.value = true
121
+ deleteDone.value = false
122
+ error.value = ''
123
+ try {
124
+ await props.deleteAll()
125
+ deleteConfirm.value = ''
126
+ deleteDone.value = true
127
+ }
128
+ catch (cause) {
129
+ error.value = cause instanceof Error ? cause.message : '게시판 데이터를 삭제하지 못했습니다.'
130
+ }
131
+ finally {
132
+ deletingAll.value = false
133
+ }
134
+ }
135
+ </script>
136
+
137
+ <template>
138
+ <div v-if="pending" class="py-16 text-center text-sm text-muted">설정을 불러오고 있습니다.</div>
139
+ <UAlert
140
+ v-else-if="!canManage"
141
+ color="neutral"
142
+ variant="subtle"
143
+ icon="i-lucide-circle-help"
144
+ title="없는 게시판입니다"
145
+ description="주소를 다시 확인해 주세요."
146
+ />
147
+ <div v-else class="flex flex-col gap-4">
148
+ <details
149
+ v-for="(category, index) in draft"
150
+ :key="category.id"
151
+ :open="index === 0"
152
+ class="group overflow-hidden rounded-lg border border-default bg-default"
153
+ >
154
+ <summary class="flex cursor-pointer list-none items-center justify-between gap-3 px-4 py-3 select-none [&::-webkit-details-marker]:hidden">
155
+ <div class="min-w-0">
156
+ <code class="text-sm font-semibold text-highlighted">{{ category.id }}</code>
157
+ <p class="mt-1 truncate text-xs text-muted">{{ category.label || '라벨 없음' }} · {{ categoryTo(category.id) }}</p>
158
+ </div>
159
+ <UIcon name="i-lucide-chevron-down" class="size-4 text-muted transition-transform group-open:rotate-180" />
160
+ </summary>
161
+
162
+ <div class="flex flex-col gap-5 border-t border-default px-4 py-4">
163
+ <div class="grid gap-2 sm:grid-cols-[9rem_1fr] sm:items-center">
164
+ <div><p class="text-sm font-medium">카테고리 ID</p><p class="text-xs text-muted">주소와 데이터 기준값</p></div>
165
+ <UInput :model-value="category.id" disabled class="font-mono" />
166
+ </div>
167
+ <div class="grid gap-2 sm:grid-cols-[9rem_1fr] sm:items-center">
168
+ <div><p class="text-sm font-medium">표시 라벨</p><p class="text-xs text-muted">사용자에게 보이는 이름</p></div>
169
+ <UInput v-model="category.label" @update:model-value="savedId = null" />
170
+ </div>
171
+ <div class="grid gap-2 sm:grid-cols-[9rem_1fr] sm:items-center">
172
+ <div><p class="text-sm font-medium">리스트뷰</p><p class="text-xs text-muted">게시글 목록 표시 방식</p></div>
173
+ <USelect
174
+ v-model="category.listView"
175
+ :items="listViewOptions"
176
+ value-key="value"
177
+ label-key="label"
178
+ @update:model-value="savedId = null"
179
+ />
180
+ </div>
181
+ <div class="flex flex-wrap justify-between gap-2 border-t border-default pt-4">
182
+ <div class="flex gap-1">
183
+ <UButton label="위로" icon="i-lucide-arrow-up" color="neutral" variant="outline" size="sm" :disabled="index === 0 || ordering" @click="moveCategory(index, -1)" />
184
+ <UButton label="아래로" icon="i-lucide-arrow-down" color="neutral" variant="outline" size="sm" :disabled="index === draft.length - 1 || ordering" @click="moveCategory(index, 1)" />
185
+ </div>
186
+ <div class="flex gap-1">
187
+ <UButton
188
+ label="게시판으로 이동"
189
+ icon="i-lucide-arrow-up-right"
190
+ color="neutral"
191
+ variant="ghost"
192
+ size="sm"
193
+ :to="categoryTo(category.id)"
194
+ />
195
+ <UButton label="카테고리 삭제" icon="i-lucide-trash-2" color="error" variant="ghost" size="sm" @click="removeCategory(index)" />
196
+ <UButton
197
+ label="저장"
198
+ icon="i-lucide-save"
199
+ size="sm"
200
+ :loading="savingId === category.id"
201
+ @click="save(category, index)"
202
+ />
203
+ </div>
204
+ </div>
205
+ </div>
206
+ </details>
207
+
208
+ <div class="flex gap-2 border-t border-default pt-4">
209
+ <UInput v-model="newLabel" class="flex-1" placeholder="새 카테고리 이름" @keyup.enter="addCategory" />
210
+ <UButton label="추가" icon="i-lucide-plus" color="neutral" variant="outline" @click="addCategory" />
211
+ </div>
212
+ <UAlert v-if="error" color="error" variant="subtle" :description="error" />
213
+ <UAlert v-if="savedId" color="success" variant="subtle" :description="`‘${draft.find(item => item.id === savedId)?.label}’ 카테고리를 저장했습니다.`" />
214
+
215
+ <section v-if="deleteAll" class="mt-4 flex flex-col gap-3 border-t border-error/40 pt-6">
216
+ <div>
217
+ <h2 class="font-semibold text-error">위험 영역</h2>
218
+ <p class="mt-1 text-sm text-muted">
219
+ 모든 게시글, 댓글, 첨부파일과 카테고리 설정을 영구 삭제합니다. 게시판 사용자와 관리자 권한은 유지합니다.
220
+ </p>
221
+ </div>
222
+ <UFormField label="확인을 위해 ‘게시판 데이터 삭제’를 입력하세요">
223
+ <UInput v-model="deleteConfirm" autocomplete="off" placeholder="게시판 데이터 삭제" />
224
+ </UFormField>
225
+ <div class="flex justify-end">
226
+ <UButton
227
+ label="모든 데이터 삭제"
228
+ icon="i-lucide-bomb"
229
+ color="error"
230
+ :disabled="deleteConfirm !== '게시판 데이터 삭제'"
231
+ :loading="deletingAll"
232
+ @click="runDeleteAll"
233
+ />
234
+ </div>
235
+ <UAlert v-if="deleteDone" color="success" variant="subtle" description="게시판 데이터를 모두 삭제했습니다." />
236
+ </section>
237
+ </div>
238
+ </template>
package/dist/types.d.ts CHANGED
@@ -22,8 +22,14 @@ export interface EditorImageEntry {
22
22
  export type ModerationStatus = 'approved';
23
23
  export interface PostModel {
24
24
  id?: string;
25
+ /** 카테고리 내 URL 식별자. 최초 작성 시 생성되고 제목 수정 시에도 유지된다. */
26
+ slug: string;
25
27
  title: string;
26
28
  summary?: string;
29
+ /** 목록 카드용 대표 이미지. 본문의 첫 이미지 또는 이미지 첨부파일. */
30
+ previewImage?: string;
31
+ /** 영상 목록 카드용 URL. 현재 YouTube 임베드 또는 영상 첨부파일. */
32
+ videoUrl?: string;
27
33
  tags?: string[];
28
34
  /** BoardCategory.id 참조. 미지정이면 '전체'로 취급. */
29
35
  category?: string;
@@ -54,15 +60,19 @@ export interface CommentModel {
54
60
  export interface BoardUserRole {
55
61
  role: 'user' | 'admin';
56
62
  }
63
+ export type BoardListView = 'default' | 'image' | 'video';
57
64
  export interface BoardCategory {
58
65
  /** 글의 category 필드가 참조하는 키 (예: 'notice', 'free') */
59
66
  id: string;
60
67
  /** 화면에 보이는 이름 */
61
68
  label: string;
69
+ /** 목록 표시 방식. 미지정된 기존 데이터는 default. */
70
+ listView?: BoardListView;
71
+ /** 목록 노출 순서. 서브컬렉션 문서의 정렬 필드. */
72
+ order?: number;
62
73
  }
63
- /** {prefix}Settings/config 문서. 관리자(role:'admin')만 생성/수정, 읽기는 전체 공개. */
74
+ /** {prefix}Settings/config 메타 문서. 카테고리는 categories 서브컬렉션에 저장한다. */
64
75
  export interface BoardSettingsModel {
65
- categories: BoardCategory[];
66
76
  updatedAt?: Timestamp;
67
77
  }
68
78
  /** 검열이 어느 단계에서 끝났는지 (호스트/로깅용, UI 노출 없음) */
@@ -0,0 +1,6 @@
1
+ import { Attachment } from '../types';
2
+ export declare function buildPostPreview(content: string, attachments?: Attachment[]): {
3
+ summary: string;
4
+ previewImage?: string;
5
+ videoUrl?: string;
6
+ };
@@ -85,13 +85,21 @@ service cloud.firestore {
85
85
  ));
86
86
  }
87
87
 
88
- // 게시판 설정(카테고리 등) — 읽기 전체 공개.
89
- // 로그인 사용자가 글쓰기 폼에서 카테고리를 추가할 수 있다 (categories + updatedAt 만).
88
+ // 게시판 설정 + 카테고리 개별 문서 — 읽기 전체 공개, 쓰기는 관리자만.
90
89
  match /memiBoardSettings/{docId} {
91
90
  allow read: if true;
92
- allow create, update: if isSignedIn()
93
- && request.resource.data.keys().hasOnly(['categories', 'updatedAt'])
94
- && request.resource.data.categories is list;
91
+ allow create, update: if isAdmin()
92
+ && request.resource.data.keys().hasOnly(['updatedAt']);
93
+
94
+ match /categories/{categoryId} {
95
+ allow read: if true;
96
+ allow create, update: if isAdmin()
97
+ && request.resource.data.keys().hasOnly(['label', 'listView', 'order', 'updatedAt'])
98
+ && request.resource.data.label is string
99
+ && request.resource.data.listView in ['default', 'image', 'video']
100
+ && request.resource.data.order is number;
101
+ allow delete: if isAdmin();
102
+ }
95
103
  }
96
104
  }
97
105
  }
@@ -28,6 +28,7 @@ service firebase.storage {
28
28
  allow read: if true;
29
29
  // uploadBytesResumable → create + 청크 update 필요 (write)
30
30
  allow write: if isSignedIn()
31
+ && (!postExists(postId) || isPostOwner(postId) || isAdmin())
31
32
  && request.resource.size < 20 * 1024 * 1024;
32
33
  allow delete: if isSignedIn() && (!postExists(postId) || isPostOwner(postId) || isAdmin());
33
34
  }
@@ -38,6 +39,7 @@ service firebase.storage {
38
39
  match /memiBoard/posts/{postId}/images/{allPaths=**} {
39
40
  allow read: if true;
40
41
  allow write: if isSignedIn()
42
+ && (!postExists(postId) || isPostOwner(postId) || isAdmin())
41
43
  && request.resource.size < 5 * 1024 * 1024
42
44
  && request.resource.contentType.matches('image/(jpeg|png|webp|gif|jpg)');
43
45
  allow delete: if isSignedIn() && (!postExists(postId) || isPostOwner(postId) || isAdmin());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memi-board",
3
- "version": "0.4.13",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "Firebase 게시판 — core composable + thin Nuxt module(UI SFC). Nuxt 4 + Nuxt UI 호스트에 붙임",
6
6
  "license": "MIT",
@@ -101,6 +101,7 @@
101
101
  ]
102
102
  },
103
103
  "dependencies": {
104
- "@nuxt/kit": "^4.5.1"
104
+ "@nuxt/kit": "^4.5.1",
105
+ "@tiptap/extension-youtube": "3.28.0"
105
106
  }
106
107
  }