memi-board 0.4.14 → 0.7.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 +13 -12
- package/dist/composables/useMemiBoardAuth.d.ts +2 -0
- package/dist/composables/useMemiBoardPosts.d.ts +8 -0
- package/dist/composables/useMemiBoardSettings.d.ts +4 -8
- package/dist/composables/useMemiBoardUsers.d.ts +11 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +390 -259
- package/dist/index.js.map +1 -1
- package/dist/module.mjs +4 -0
- package/dist/module.mjs.map +1 -1
- package/dist/runtime/components/Attachments.vue +1 -1
- package/dist/runtime/components/Detail.vue +109 -40
- package/dist/runtime/components/Editor.vue +62 -23
- package/dist/runtime/components/List.vue +19 -49
- package/dist/runtime/components/ListDefault.vue +41 -0
- package/dist/runtime/components/ListImage.vue +41 -0
- package/dist/runtime/components/ListVideo.vue +44 -0
- package/dist/runtime/components/Settings.vue +137 -33
- package/dist/runtime/components/Users.vue +64 -0
- package/dist/types.d.ts +21 -4
- package/dist/utils/postPreview.d.ts +6 -0
- package/docs/firestore.rules.example +36 -15
- package/docs/storage.rules.example +6 -4
- package/package.json +3 -2
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { computed, ref, watch } from 'vue'
|
|
3
|
-
import { useMemiBoardAuth } from '
|
|
4
|
-
import {
|
|
5
|
-
import { slugify } from '../utils/slugify'
|
|
6
|
-
import type { BoardCategory, BoardListView } from '../types'
|
|
2
|
+
import { computed, onMounted, ref, watch } from 'vue'
|
|
3
|
+
import { slugify, useMemiBoardAuth, useMemiBoardSettings } from 'memi-board'
|
|
4
|
+
import type { BoardCategory, BoardListView, BoardWriteRole } from 'memi-board'
|
|
7
5
|
|
|
8
6
|
const props = withDefaults(defineProps<{
|
|
9
7
|
/** 호스트 자체 관리자 권한도 허용할 때 true. Firebase rules 권한은 호스트가 별도로 맞춰야 한다. */
|
|
10
8
|
authorized?: boolean
|
|
9
|
+
/** 카테고리 페이지 기본 경로. 기본 `/board` → `/board/{id}` */
|
|
10
|
+
categoryBase?: string
|
|
11
|
+
/** 호스트 서버에서 게시판 전체 데이터를 삭제하는 관리자 전용 작업. */
|
|
12
|
+
deleteAll?: () => Promise<void>
|
|
11
13
|
}>(), {
|
|
12
14
|
authorized: false,
|
|
15
|
+
categoryBase: '/board',
|
|
13
16
|
})
|
|
14
17
|
|
|
15
18
|
const emit = defineEmits<{
|
|
@@ -21,23 +24,37 @@ const listViewOptions: Array<{ label: string, value: BoardListView }> = [
|
|
|
21
24
|
{ label: '이미지', value: 'image' },
|
|
22
25
|
{ label: '영상', value: 'video' },
|
|
23
26
|
]
|
|
27
|
+
const writeRoleOptions: Array<{ label: string, value: BoardWriteRole }> = [
|
|
28
|
+
{ label: '일반 이상', value: 'user' },
|
|
29
|
+
{ label: '스태프 이상', value: 'staff' },
|
|
30
|
+
{ label: '관리자만', value: 'admin' },
|
|
31
|
+
]
|
|
24
32
|
|
|
25
33
|
const { isAdmin, rolePending } = useMemiBoardAuth()
|
|
26
|
-
const { categories, settingsPending, saveCategories } = useMemiBoardSettings()
|
|
34
|
+
const { categories, settingsPending, saveCategory, saveCategories, deleteCategory } = useMemiBoardSettings()
|
|
27
35
|
const canManage = computed(() => isAdmin.value || props.authorized)
|
|
28
|
-
|
|
36
|
+
// Firebase의 SSR pending 값과 클라이언트 첫 값이 달라질 수 있다. 최초 hydration은
|
|
37
|
+
// 양쪽 모두 로딩 화면으로 맞춘 뒤 mounted 이후 실제 상태를 렌더링한다.
|
|
38
|
+
const mounted = ref(false)
|
|
39
|
+
onMounted(() => { mounted.value = true })
|
|
40
|
+
const pending = computed(() => !mounted.value || rolePending.value || settingsPending.value)
|
|
29
41
|
|
|
30
42
|
const draft = ref<BoardCategory[]>([])
|
|
31
43
|
const newLabel = ref('')
|
|
32
|
-
const
|
|
33
|
-
const
|
|
44
|
+
const savingId = ref<string | null>(null)
|
|
45
|
+
const savedId = ref<string | null>(null)
|
|
46
|
+
const ordering = ref(false)
|
|
34
47
|
const error = ref('')
|
|
48
|
+
const deleteConfirm = ref('')
|
|
49
|
+
const deletingAll = ref(false)
|
|
50
|
+
const deleteDone = ref(false)
|
|
35
51
|
|
|
36
52
|
watch([categories, settingsPending], ([list, loading]) => {
|
|
37
53
|
if (!loading && draft.value.length === 0) {
|
|
38
54
|
draft.value = list.map(category => ({
|
|
39
55
|
...category,
|
|
40
56
|
listView: category.listView ?? 'default',
|
|
57
|
+
writeRole: category.writeRole ?? 'user',
|
|
41
58
|
}))
|
|
42
59
|
}
|
|
43
60
|
}, { immediate: true })
|
|
@@ -49,40 +66,80 @@ function addCategory() {
|
|
|
49
66
|
let id = base
|
|
50
67
|
let suffix = 2
|
|
51
68
|
while (draft.value.some(category => category.id === id)) id = `${base}-${suffix++}`
|
|
52
|
-
draft.value.push({ id, label, listView: 'default' })
|
|
69
|
+
draft.value.push({ id, label, listView: 'default', writeRole: 'user' })
|
|
53
70
|
newLabel.value = ''
|
|
54
|
-
saved.value = false
|
|
55
71
|
}
|
|
56
72
|
|
|
57
|
-
function moveCategory(index: number, offset: -1 | 1) {
|
|
73
|
+
async function moveCategory(index: number, offset: -1 | 1) {
|
|
58
74
|
const target = index + offset
|
|
59
75
|
if (target < 0 || target >= draft.value.length) return
|
|
60
76
|
const next = [...draft.value]
|
|
61
77
|
const [item] = next.splice(index, 1)
|
|
62
78
|
next.splice(target, 0, item!)
|
|
63
|
-
draft.value = next
|
|
64
|
-
|
|
79
|
+
draft.value = next.map((category, order) => ({ ...category, order }))
|
|
80
|
+
ordering.value = true
|
|
81
|
+
error.value = ''
|
|
82
|
+
try {
|
|
83
|
+
await saveCategories(draft.value)
|
|
84
|
+
}
|
|
85
|
+
catch (cause) {
|
|
86
|
+
error.value = cause instanceof Error ? cause.message : '카테고리 순서를 저장하지 못했습니다.'
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
ordering.value = false
|
|
90
|
+
}
|
|
65
91
|
}
|
|
66
92
|
|
|
67
|
-
function removeCategory(index: number) {
|
|
68
|
-
draft.value
|
|
69
|
-
|
|
93
|
+
async function removeCategory(index: number) {
|
|
94
|
+
const category = draft.value[index]
|
|
95
|
+
if (!category || !window.confirm(`‘${category.label}’ 카테고리를 삭제하시겠습니까?`)) return
|
|
96
|
+
error.value = ''
|
|
97
|
+
try {
|
|
98
|
+
await deleteCategory(category.id)
|
|
99
|
+
draft.value.splice(index, 1)
|
|
100
|
+
}
|
|
101
|
+
catch (cause) {
|
|
102
|
+
error.value = cause instanceof Error ? cause.message : '카테고리를 삭제하지 못했습니다.'
|
|
103
|
+
}
|
|
70
104
|
}
|
|
71
105
|
|
|
72
|
-
|
|
106
|
+
function categoryTo(id: string) {
|
|
107
|
+
return `${props.categoryBase.replace(/\/$/, '')}/${encodeURIComponent(id)}`
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function save(category: BoardCategory, index: number) {
|
|
73
111
|
error.value = ''
|
|
74
|
-
|
|
75
|
-
|
|
112
|
+
savedId.value = null
|
|
113
|
+
savingId.value = category.id
|
|
76
114
|
try {
|
|
77
|
-
await
|
|
78
|
-
|
|
115
|
+
await saveCategory(category, index)
|
|
116
|
+
category.order = index
|
|
117
|
+
savedId.value = category.id
|
|
79
118
|
emit('saved', draft.value)
|
|
80
119
|
}
|
|
81
120
|
catch (cause) {
|
|
82
121
|
error.value = cause instanceof Error ? cause.message : '게시판 설정을 저장하지 못했습니다.'
|
|
83
122
|
}
|
|
84
123
|
finally {
|
|
85
|
-
|
|
124
|
+
savingId.value = null
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function runDeleteAll() {
|
|
129
|
+
if (!props.deleteAll || deleteConfirm.value !== '게시판 데이터 삭제') return
|
|
130
|
+
deletingAll.value = true
|
|
131
|
+
deleteDone.value = false
|
|
132
|
+
error.value = ''
|
|
133
|
+
try {
|
|
134
|
+
await props.deleteAll()
|
|
135
|
+
deleteConfirm.value = ''
|
|
136
|
+
deleteDone.value = true
|
|
137
|
+
}
|
|
138
|
+
catch (cause) {
|
|
139
|
+
error.value = cause instanceof Error ? cause.message : '게시판 데이터를 삭제하지 못했습니다.'
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
142
|
+
deletingAll.value = false
|
|
86
143
|
}
|
|
87
144
|
}
|
|
88
145
|
</script>
|
|
@@ -107,7 +164,7 @@ async function save() {
|
|
|
107
164
|
<summary class="flex cursor-pointer list-none items-center justify-between gap-3 px-4 py-3 select-none [&::-webkit-details-marker]:hidden">
|
|
108
165
|
<div class="min-w-0">
|
|
109
166
|
<code class="text-sm font-semibold text-highlighted">{{ category.id }}</code>
|
|
110
|
-
<p class="mt-1 truncate text-xs text-muted">{{ category.label || '라벨 없음' }} ·
|
|
167
|
+
<p class="mt-1 truncate text-xs text-muted">{{ category.label || '라벨 없음' }} · {{ categoryTo(category.id) }}</p>
|
|
111
168
|
</div>
|
|
112
169
|
<UIcon name="i-lucide-chevron-down" class="size-4 text-muted transition-transform group-open:rotate-180" />
|
|
113
170
|
</summary>
|
|
@@ -117,9 +174,19 @@ async function save() {
|
|
|
117
174
|
<div><p class="text-sm font-medium">카테고리 ID</p><p class="text-xs text-muted">주소와 데이터 기준값</p></div>
|
|
118
175
|
<UInput :model-value="category.id" disabled class="font-mono" />
|
|
119
176
|
</div>
|
|
177
|
+
<div class="grid gap-2 sm:grid-cols-[9rem_1fr] sm:items-center">
|
|
178
|
+
<div><p class="text-sm font-medium">글쓰기 권한</p><p class="text-xs text-muted">이 카테고리에 글을 쓸 수 있는 최소 역할</p></div>
|
|
179
|
+
<USelect
|
|
180
|
+
v-model="category.writeRole"
|
|
181
|
+
:items="writeRoleOptions"
|
|
182
|
+
value-key="value"
|
|
183
|
+
label-key="label"
|
|
184
|
+
@update:model-value="savedId = null"
|
|
185
|
+
/>
|
|
186
|
+
</div>
|
|
120
187
|
<div class="grid gap-2 sm:grid-cols-[9rem_1fr] sm:items-center">
|
|
121
188
|
<div><p class="text-sm font-medium">표시 라벨</p><p class="text-xs text-muted">사용자에게 보이는 이름</p></div>
|
|
122
|
-
<UInput v-model="category.label" @update:model-value="
|
|
189
|
+
<UInput v-model="category.label" @update:model-value="savedId = null" />
|
|
123
190
|
</div>
|
|
124
191
|
<div class="grid gap-2 sm:grid-cols-[9rem_1fr] sm:items-center">
|
|
125
192
|
<div><p class="text-sm font-medium">리스트뷰</p><p class="text-xs text-muted">게시글 목록 표시 방식</p></div>
|
|
@@ -128,15 +195,32 @@ async function save() {
|
|
|
128
195
|
:items="listViewOptions"
|
|
129
196
|
value-key="value"
|
|
130
197
|
label-key="label"
|
|
131
|
-
@update:model-value="
|
|
198
|
+
@update:model-value="savedId = null"
|
|
132
199
|
/>
|
|
133
200
|
</div>
|
|
134
201
|
<div class="flex flex-wrap justify-between gap-2 border-t border-default pt-4">
|
|
135
202
|
<div class="flex gap-1">
|
|
136
|
-
<UButton label="위로" icon="i-lucide-arrow-up" color="neutral" variant="outline" size="sm" :disabled="index === 0" @click="moveCategory(index, -1)" />
|
|
137
|
-
<UButton label="아래로" icon="i-lucide-arrow-down" color="neutral" variant="outline" size="sm" :disabled="index === draft.length - 1" @click="moveCategory(index, 1)" />
|
|
203
|
+
<UButton label="위로" icon="i-lucide-arrow-up" color="neutral" variant="outline" size="sm" :disabled="index === 0 || ordering" @click="moveCategory(index, -1)" />
|
|
204
|
+
<UButton label="아래로" icon="i-lucide-arrow-down" color="neutral" variant="outline" size="sm" :disabled="index === draft.length - 1 || ordering" @click="moveCategory(index, 1)" />
|
|
205
|
+
</div>
|
|
206
|
+
<div class="flex gap-1">
|
|
207
|
+
<UButton
|
|
208
|
+
label="게시판으로 이동"
|
|
209
|
+
icon="i-lucide-arrow-up-right"
|
|
210
|
+
color="neutral"
|
|
211
|
+
variant="ghost"
|
|
212
|
+
size="sm"
|
|
213
|
+
:to="categoryTo(category.id)"
|
|
214
|
+
/>
|
|
215
|
+
<UButton label="카테고리 삭제" icon="i-lucide-trash-2" color="error" variant="ghost" size="sm" @click="removeCategory(index)" />
|
|
216
|
+
<UButton
|
|
217
|
+
label="저장"
|
|
218
|
+
icon="i-lucide-save"
|
|
219
|
+
size="sm"
|
|
220
|
+
:loading="savingId === category.id"
|
|
221
|
+
@click="save(category, index)"
|
|
222
|
+
/>
|
|
138
223
|
</div>
|
|
139
|
-
<UButton label="카테고리 삭제" icon="i-lucide-trash-2" color="error" variant="ghost" size="sm" @click="removeCategory(index)" />
|
|
140
224
|
</div>
|
|
141
225
|
</div>
|
|
142
226
|
</details>
|
|
@@ -146,9 +230,29 @@ async function save() {
|
|
|
146
230
|
<UButton label="추가" icon="i-lucide-plus" color="neutral" variant="outline" @click="addCategory" />
|
|
147
231
|
</div>
|
|
148
232
|
<UAlert v-if="error" color="error" variant="subtle" :description="error" />
|
|
149
|
-
<UAlert v-if="
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
233
|
+
<UAlert v-if="savedId" color="success" variant="subtle" :description="`‘${draft.find(item => item.id === savedId)?.label}’ 카테고리를 저장했습니다.`" />
|
|
234
|
+
|
|
235
|
+
<section v-if="deleteAll" class="mt-4 flex flex-col gap-3 border-t border-error/40 pt-6">
|
|
236
|
+
<div>
|
|
237
|
+
<h2 class="font-semibold text-error">위험 영역</h2>
|
|
238
|
+
<p class="mt-1 text-sm text-muted">
|
|
239
|
+
모든 게시글, 댓글, 첨부파일과 카테고리 설정을 영구 삭제합니다. 게시판 사용자와 관리자 권한은 유지합니다.
|
|
240
|
+
</p>
|
|
241
|
+
</div>
|
|
242
|
+
<UFormField label="확인을 위해 ‘게시판 데이터 삭제’를 입력하세요">
|
|
243
|
+
<UInput v-model="deleteConfirm" autocomplete="off" placeholder="게시판 데이터 삭제" />
|
|
244
|
+
</UFormField>
|
|
245
|
+
<div class="flex justify-end">
|
|
246
|
+
<UButton
|
|
247
|
+
label="모든 데이터 삭제"
|
|
248
|
+
icon="i-lucide-bomb"
|
|
249
|
+
color="error"
|
|
250
|
+
:disabled="deleteConfirm !== '게시판 데이터 삭제'"
|
|
251
|
+
:loading="deletingAll"
|
|
252
|
+
@click="runDeleteAll"
|
|
253
|
+
/>
|
|
254
|
+
</div>
|
|
255
|
+
<UAlert v-if="deleteDone" color="success" variant="subtle" description="게시판 데이터를 모두 삭제했습니다." />
|
|
256
|
+
</section>
|
|
153
257
|
</div>
|
|
154
258
|
</template>
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref } from 'vue'
|
|
3
|
+
import { useMemiBoardAuth } from '../composables/useMemiBoardAuth'
|
|
4
|
+
import { BOARD_USER_ROLES, useMemiBoardUsers } from '../composables/useMemiBoardUsers'
|
|
5
|
+
import type { BoardUserRole } from '../types'
|
|
6
|
+
|
|
7
|
+
const props = withDefaults(defineProps<{ authorized?: boolean }>(), { authorized: false })
|
|
8
|
+
const { isAdmin, rolePending } = useMemiBoardAuth()
|
|
9
|
+
const { users, usersPending, updateUserRole } = useMemiBoardUsers()
|
|
10
|
+
const canManage = computed(() => isAdmin.value || props.authorized)
|
|
11
|
+
const savingUid = ref<string | null>(null)
|
|
12
|
+
const savedUid = ref<string | null>(null)
|
|
13
|
+
const error = ref('')
|
|
14
|
+
|
|
15
|
+
async function changeRole(uid: string, role: BoardUserRole) {
|
|
16
|
+
if (!canManage.value) return
|
|
17
|
+
savingUid.value = uid
|
|
18
|
+
savedUid.value = null
|
|
19
|
+
error.value = ''
|
|
20
|
+
try {
|
|
21
|
+
await updateUserRole(uid, role)
|
|
22
|
+
savedUid.value = uid
|
|
23
|
+
}
|
|
24
|
+
catch (cause) {
|
|
25
|
+
error.value = cause instanceof Error ? cause.message : '역할을 변경하지 못했습니다.'
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
savingUid.value = null
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<template>
|
|
34
|
+
<div class="flex flex-col gap-6">
|
|
35
|
+
<div class="grid gap-3 md:grid-cols-3">
|
|
36
|
+
<UAlert v-for="item in BOARD_USER_ROLES" :key="item.value" color="neutral" variant="subtle" :title="item.label" :description="item.description" />
|
|
37
|
+
</div>
|
|
38
|
+
<UAlert v-if="error" color="error" variant="subtle" icon="i-lucide-circle-alert" :description="error" />
|
|
39
|
+
<div v-if="rolePending || usersPending" class="py-12 text-center text-sm text-muted">사용자 목록을 불러오고 있습니다.</div>
|
|
40
|
+
<UAlert v-else-if="!canManage" color="error" variant="subtle" icon="i-lucide-shield-alert" title="권한이 없습니다" description="게시판 관리자만 사용자 역할을 관리할 수 있습니다." />
|
|
41
|
+
<div v-else-if="users.length === 0" class="py-12 text-center text-sm text-muted">게시판에 로그인한 사용자가 없습니다.</div>
|
|
42
|
+
<div v-else class="divide-y divide-default rounded-lg border border-default">
|
|
43
|
+
<div v-for="boardUser in users" :key="boardUser.id" class="grid gap-4 p-4 md:grid-cols-[minmax(0,1fr)_12rem] md:items-center">
|
|
44
|
+
<div class="min-w-0">
|
|
45
|
+
<div class="flex items-center gap-2">
|
|
46
|
+
<p class="truncate font-medium text-highlighted">{{ boardUser.displayName || '이름 없음' }}</p>
|
|
47
|
+
<UBadge v-if="savedUid === boardUser.id" color="success" variant="subtle" label="저장됨" />
|
|
48
|
+
</div>
|
|
49
|
+
<p class="truncate text-sm text-muted">{{ boardUser.email || boardUser.id }}</p>
|
|
50
|
+
<p v-if="boardUser.moderationBlockCount" class="mt-1 text-xs text-warning">콘텐츠 경고 {{ boardUser.moderationBlockCount }}회</p>
|
|
51
|
+
</div>
|
|
52
|
+
<USelect
|
|
53
|
+
:model-value="boardUser.role || 'user'"
|
|
54
|
+
:items="BOARD_USER_ROLES"
|
|
55
|
+
value-key="value"
|
|
56
|
+
label-key="label"
|
|
57
|
+
:loading="savingUid === boardUser.id"
|
|
58
|
+
:disabled="savingUid === boardUser.id"
|
|
59
|
+
@update:model-value="changeRole(boardUser.id, $event as BoardUserRole)"
|
|
60
|
+
/>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
</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;
|
|
@@ -51,10 +57,18 @@ export interface CommentModel {
|
|
|
51
57
|
moderationStatus: ModerationStatus;
|
|
52
58
|
createdAt: Timestamp;
|
|
53
59
|
}
|
|
54
|
-
export
|
|
55
|
-
|
|
60
|
+
export type BoardUserRole = 'admin' | 'staff' | 'user';
|
|
61
|
+
export interface BoardUserModel {
|
|
62
|
+
id: string;
|
|
63
|
+
role: BoardUserRole;
|
|
64
|
+
displayName?: string | null;
|
|
65
|
+
email?: string | null;
|
|
66
|
+
photoURL?: string | null;
|
|
67
|
+
moderationBlockCount?: number;
|
|
68
|
+
updatedAt?: Timestamp;
|
|
56
69
|
}
|
|
57
70
|
export type BoardListView = 'default' | 'image' | 'video';
|
|
71
|
+
export type BoardWriteRole = 'user' | 'staff' | 'admin';
|
|
58
72
|
export interface BoardCategory {
|
|
59
73
|
/** 글의 category 필드가 참조하는 키 (예: 'notice', 'free') */
|
|
60
74
|
id: string;
|
|
@@ -62,10 +76,13 @@ export interface BoardCategory {
|
|
|
62
76
|
label: string;
|
|
63
77
|
/** 목록 표시 방식. 미지정된 기존 데이터는 default. */
|
|
64
78
|
listView?: BoardListView;
|
|
79
|
+
/** 글쓰기 최소 역할. 미지정된 기존 데이터는 user. */
|
|
80
|
+
writeRole?: BoardWriteRole;
|
|
81
|
+
/** 목록 노출 순서. 서브컬렉션 문서의 정렬 필드. */
|
|
82
|
+
order?: number;
|
|
65
83
|
}
|
|
66
|
-
/** {prefix}Settings/config 문서.
|
|
84
|
+
/** {prefix}Settings/config 메타 문서. 카테고리는 categories 서브컬렉션에 저장한다. */
|
|
67
85
|
export interface BoardSettingsModel {
|
|
68
|
-
categories: BoardCategory[];
|
|
69
86
|
updatedAt?: Timestamp;
|
|
70
87
|
}
|
|
71
88
|
/** 검열이 어느 단계에서 끝났는지 (호스트/로깅용, UI 노출 없음) */
|
|
@@ -19,6 +19,18 @@ service cloud.firestore {
|
|
|
19
19
|
return isSignedIn() &&
|
|
20
20
|
get(/databases/$(database)/documents/memiBoardUsers/$(request.auth.uid)).data.role == 'admin';
|
|
21
21
|
}
|
|
22
|
+
function isStaff() {
|
|
23
|
+
return isSignedIn() &&
|
|
24
|
+
get(/databases/$(database)/documents/memiBoardUsers/$(request.auth.uid)).data.role in ['admin', 'staff'];
|
|
25
|
+
}
|
|
26
|
+
function canWriteCategory(categoryId) {
|
|
27
|
+
let categoryPath = /databases/$(database)/documents/memiBoardSettings/config/categories/$(categoryId);
|
|
28
|
+
let requiredRole = get(categoryPath).data.get('writeRole', 'user');
|
|
29
|
+
return exists(categoryPath)
|
|
30
|
+
&& (requiredRole == 'user'
|
|
31
|
+
|| (requiredRole == 'staff' && isStaff())
|
|
32
|
+
|| (requiredRole == 'admin' && isAdmin()));
|
|
33
|
+
}
|
|
22
34
|
function isOwner(resource) {
|
|
23
35
|
return isSignedIn() && request.auth.uid == resource.data.authorUid;
|
|
24
36
|
}
|
|
@@ -28,14 +40,16 @@ service cloud.firestore {
|
|
|
28
40
|
|
|
29
41
|
allow create: if isSignedIn()
|
|
30
42
|
&& request.resource.data.authorUid == request.auth.uid
|
|
31
|
-
&& request.resource.data.moderationStatus == 'approved'
|
|
43
|
+
&& request.resource.data.moderationStatus == 'approved'
|
|
44
|
+
&& request.resource.data.category is string
|
|
45
|
+
&& canWriteCategory(request.resource.data.category);
|
|
32
46
|
|
|
33
|
-
// 작성자 본인 또는
|
|
34
|
-
allow update, delete: if isOwner(resource) ||
|
|
47
|
+
// 작성자 본인 또는 관리자·스태프만 수정/삭제 가능
|
|
48
|
+
allow update, delete: if isOwner(resource) || isStaff();
|
|
35
49
|
|
|
36
50
|
match /body/{docId} {
|
|
37
51
|
allow read: if true;
|
|
38
|
-
allow write: if isOwner(get(/databases/$(database)/documents/memiBoardPosts/$(postId))) ||
|
|
52
|
+
allow write: if isOwner(get(/databases/$(database)/documents/memiBoardPosts/$(postId))) || isStaff();
|
|
39
53
|
}
|
|
40
54
|
|
|
41
55
|
match /comments/{commentId} {
|
|
@@ -49,7 +63,7 @@ service cloud.firestore {
|
|
|
49
63
|
// 부모 글을 삭제할 때 작성자가 다른 사용자의 댓글도 함께 정리할 수 있어야 한다.
|
|
50
64
|
allow delete: if isOwner(resource)
|
|
51
65
|
|| isOwner(get(/databases/$(database)/documents/memiBoardPosts/$(postId)))
|
|
52
|
-
||
|
|
66
|
+
|| isStaff();
|
|
53
67
|
|
|
54
68
|
// 댓글 수정은 지원하지 않는다 (삭제 후 재작성만 가능) — Editor에서 UI 자체도 제공하지 않음
|
|
55
69
|
allow update: if false;
|
|
@@ -57,19 +71,17 @@ service cloud.firestore {
|
|
|
57
71
|
}
|
|
58
72
|
|
|
59
73
|
// 게시판 역할 문서.
|
|
60
|
-
// 본인은 role:'user'로만
|
|
61
|
-
// 관리자 승격은 Firebase 콘솔에서 해당 uid 문서의 role 필드를 직접 'admin'으로 바꿔야 한다
|
|
62
|
-
// (이 모듈은 관리자 승격 UI를 제공하지 않는다).
|
|
74
|
+
// 본인은 role:'user'로만 생성하며, admin은 Users UI에서 admin/staff/user를 변경한다.
|
|
63
75
|
// moderationBlockCount/At: 검열 차단 누적(+1만). 감소·리셋은 admin.
|
|
64
76
|
match /memiBoardUsers/{uid} {
|
|
65
|
-
allow read: if isSignedIn();
|
|
77
|
+
allow read: if isAdmin() || (isSignedIn() && request.auth.uid == uid);
|
|
66
78
|
allow create: if isSignedIn()
|
|
67
79
|
&& request.auth.uid == uid
|
|
68
80
|
&& request.resource.data.role == 'user'
|
|
69
81
|
&& (!('moderationBlockCount' in request.resource.data)
|
|
70
82
|
|| request.resource.data.moderationBlockCount == 0
|
|
71
83
|
|| request.resource.data.moderationBlockCount == 1);
|
|
72
|
-
allow update: if isAdmin()
|
|
84
|
+
allow update: if (isAdmin() && request.resource.data.role in ['admin', 'staff', 'user'])
|
|
73
85
|
|| (isSignedIn()
|
|
74
86
|
&& request.auth.uid == uid
|
|
75
87
|
&& request.resource.data.role == resource.data.role
|
|
@@ -85,13 +97,22 @@ service cloud.firestore {
|
|
|
85
97
|
));
|
|
86
98
|
}
|
|
87
99
|
|
|
88
|
-
// 게시판 설정
|
|
89
|
-
// 로그인 사용자가 글쓰기 폼에서 카테고리를 추가할 수 있다 (categories + updatedAt 만).
|
|
100
|
+
// 게시판 설정 + 카테고리 개별 문서 — 읽기 전체 공개, 쓰기는 관리자만.
|
|
90
101
|
match /memiBoardSettings/{docId} {
|
|
91
102
|
allow read: if true;
|
|
92
|
-
allow create, update: if
|
|
93
|
-
&& request.resource.data.keys().hasOnly(['
|
|
94
|
-
|
|
103
|
+
allow create, update: if isAdmin()
|
|
104
|
+
&& request.resource.data.keys().hasOnly(['updatedAt']);
|
|
105
|
+
|
|
106
|
+
match /categories/{categoryId} {
|
|
107
|
+
allow read: if true;
|
|
108
|
+
allow create, update: if isAdmin()
|
|
109
|
+
&& request.resource.data.keys().hasOnly(['label', 'listView', 'writeRole', 'order', 'updatedAt'])
|
|
110
|
+
&& request.resource.data.label is string
|
|
111
|
+
&& request.resource.data.listView in ['default', 'image', 'video']
|
|
112
|
+
&& request.resource.data.writeRole in ['user', 'staff', 'admin']
|
|
113
|
+
&& request.resource.data.order is number;
|
|
114
|
+
allow delete: if isAdmin();
|
|
115
|
+
}
|
|
95
116
|
}
|
|
96
117
|
}
|
|
97
118
|
}
|
|
@@ -12,9 +12,9 @@ service firebase.storage {
|
|
|
12
12
|
function isSignedIn() {
|
|
13
13
|
return request.auth != null;
|
|
14
14
|
}
|
|
15
|
-
function
|
|
15
|
+
function isStaff() {
|
|
16
16
|
return isSignedIn() &&
|
|
17
|
-
firestore.get(/databases/(default)/documents/memiBoardUsers/$(request.auth.uid)).data.role
|
|
17
|
+
firestore.get(/databases/(default)/documents/memiBoardUsers/$(request.auth.uid)).data.role in ['admin', 'staff'];
|
|
18
18
|
}
|
|
19
19
|
function postExists(postId) {
|
|
20
20
|
return firestore.exists(/databases/(default)/documents/memiBoardPosts/$(postId));
|
|
@@ -28,8 +28,9 @@ 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) || isStaff())
|
|
31
32
|
&& request.resource.size < 20 * 1024 * 1024;
|
|
32
|
-
allow delete: if isSignedIn() && (!postExists(postId) || isPostOwner(postId) ||
|
|
33
|
+
allow delete: if isSignedIn() && (!postExists(postId) || isPostOwner(postId) || isStaff());
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
// 에디터 본문 이미지 + 썸네일
|
|
@@ -38,9 +39,10 @@ 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) || isStaff())
|
|
41
43
|
&& request.resource.size < 5 * 1024 * 1024
|
|
42
44
|
&& request.resource.contentType.matches('image/(jpeg|png|webp|gif|jpg)');
|
|
43
|
-
allow delete: if isSignedIn() && (!postExists(postId) || isPostOwner(postId) ||
|
|
45
|
+
allow delete: if isSignedIn() && (!postExists(postId) || isPostOwner(postId) || isStaff());
|
|
44
46
|
}
|
|
45
47
|
}
|
|
46
48
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memi-board",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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
|
}
|