memi-board 0.52.4 → 0.53.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 +4 -6
- package/dist/{ar-3hd6xh6U.js → ar-DBv_xFzj.js} +3 -2
- package/dist/ar-DBv_xFzj.js.map +1 -0
- package/dist/{commentImages-4IOk2u0O.js → commentImages-t4lu_hxD.js} +130 -99
- package/dist/commentImages-t4lu_hxD.js.map +1 -0
- package/dist/composables/useMemiBoardModeration.d.ts +1 -3
- package/dist/composables/useMemiBoardPosts.d.ts +6 -0
- package/dist/{de-DXtmk-4H.js → de-DiqhMTJO.js} +3 -2
- package/dist/de-DiqhMTJO.js.map +1 -0
- package/dist/{en-CyZH4Ct1.js → en-hMF_prdm.js} +33 -3
- package/dist/en-hMF_prdm.js.map +1 -0
- package/dist/{es-BtRbHicT.js → es-RfbaViQj.js} +3 -2
- package/dist/es-RfbaViQj.js.map +1 -0
- package/dist/{fr-vnizG3rD.js → fr-BlRWQtSh.js} +3 -2
- package/dist/fr-BlRWQtSh.js.map +1 -0
- package/dist/{id-Cf8yw8n_.js → id-C0-e3Gwc.js} +3 -2
- package/dist/id-C0-e3Gwc.js.map +1 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1172 -1059
- package/dist/index.js.map +1 -1
- package/dist/{ja-D-Nrtjk-.js → ja-q1DilxZV.js} +3 -2
- package/dist/ja-q1DilxZV.js.map +1 -0
- package/dist/{pt-CSsshr7u.js → pt-sB9letRc.js} +3 -2
- package/dist/pt-sB9letRc.js.map +1 -0
- package/dist/runtime/components/Attachments.vue +82 -22
- package/dist/runtime/components/CategorySettings.vue +4 -0
- package/dist/runtime/components/CustomFieldEditor.vue +86 -0
- package/dist/runtime/components/Detail.vue +15 -1
- package/dist/runtime/components/Editor.vue +49 -12
- package/dist/runtime/components/Settings.vue +190 -13
- package/dist/storage.js +36 -36
- package/dist/types.d.ts +23 -1
- package/dist/utils/customFields.d.ts +33 -0
- package/dist/utils/slugify.d.ts +2 -0
- package/dist/{zh-BzrsqVN2.js → zh-Dag8WCjt.js} +3 -2
- package/dist/zh-Dag8WCjt.js.map +1 -0
- package/docs/firebase-setup.md +64 -0
- package/docs/host-integration.md +4 -0
- package/package.json +1 -1
- package/dist/ar-3hd6xh6U.js.map +0 -1
- package/dist/commentImages-4IOk2u0O.js.map +0 -1
- package/dist/de-DXtmk-4H.js.map +0 -1
- package/dist/en-CyZH4Ct1.js.map +0 -1
- package/dist/es-BtRbHicT.js.map +0 -1
- package/dist/fr-vnizG3rD.js.map +0 -1
- package/dist/id-Cf8yw8n_.js.map +0 -1
- package/dist/ja-D-Nrtjk-.js.map +0 -1
- package/dist/pt-CSsshr7u.js.map +0 -1
- package/dist/zh-BzrsqVN2.js.map +0 -1
|
@@ -27,6 +27,9 @@ import {
|
|
|
27
27
|
buildPostPreview,
|
|
28
28
|
hashText,
|
|
29
29
|
useMemiBoardWritingAssistant,
|
|
30
|
+
CUSTOM_FIELD_VALUE_MAX,
|
|
31
|
+
extendForFields,
|
|
32
|
+
readExtend,
|
|
30
33
|
} from 'memi-board/runtime'
|
|
31
34
|
import { useMemiBoardStorage, EDITOR_IMAGE_SOURCE_MAX_BYTES } from 'memi-board/storage'
|
|
32
35
|
import MemiBoardAttachments from './Attachments.vue'
|
|
@@ -41,7 +44,7 @@ const props = defineProps<{
|
|
|
41
44
|
|
|
42
45
|
const emit = defineEmits<{ saved: [id: string], cancel: [] }>()
|
|
43
46
|
|
|
44
|
-
const { user, isSignedIn,
|
|
47
|
+
const { user, isSignedIn, isWriteRestricted, restrictedMessage, canManageBoard } = useMemiBoardAuth()
|
|
45
48
|
const resolvedBoardId = computed(() => props.boardId || props.fixedCategory || '')
|
|
46
49
|
const { createPostId, getPost, createPost, updatePost, savePostTranslations, resolveUniqueSlug, setPostPinned } = useMemiBoardPosts(resolvedBoardId)
|
|
47
50
|
const { getBoard, ensureSettings } = useMemiBoardSettings()
|
|
@@ -53,6 +56,10 @@ const { uploadEditorImage } = useMemiBoardStorage()
|
|
|
53
56
|
const { assist, translatePost } = useMemiBoardWritingAssistant({ locale })
|
|
54
57
|
|
|
55
58
|
const title = ref('')
|
|
59
|
+
/** 보드 커스텀 필드 값. 키는 설정에 있는 것만 저장한다. */
|
|
60
|
+
const extendValues = ref<Record<string, string>>({})
|
|
61
|
+
/** 글을 열 때 읽은 값. 설정이 늦게 도착해도 입력칸을 채운다. */
|
|
62
|
+
const loadedExtend = ref<Record<string, string>>({})
|
|
56
63
|
/** UEditor content-type=html (shineb 와 동일) */
|
|
57
64
|
const content = ref('')
|
|
58
65
|
const tags = ref<string[]>([])
|
|
@@ -63,6 +70,28 @@ const isImageEditor = computed(() => getBoard(resolvedBoardId.value)?.editorType
|
|
|
63
70
|
/** 보드 설정의 번역 언어(비어 있으면 전체) */
|
|
64
71
|
const translationTargets = computed(() => boardTranslationLocales(getBoard(resolvedBoardId.value)))
|
|
65
72
|
const isEdit = computed(() => Boolean(props.postId))
|
|
73
|
+
const customFields = computed(() => getBoard(resolvedBoardId.value)?.customFields ?? [])
|
|
74
|
+
|
|
75
|
+
function blankExtend(): Record<string, string> {
|
|
76
|
+
return Object.fromEntries(customFields.value.map(field => [field.key, '']))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function storedExtend(value: unknown): Record<string, string> {
|
|
80
|
+
const stored = readExtend(value)
|
|
81
|
+
return Object.fromEntries(customFields.value.map(field => [field.key, stored[field.key] ?? '']))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
watch(customFields, (fields) => {
|
|
85
|
+
const next = { ...extendValues.value }
|
|
86
|
+
let changed = false
|
|
87
|
+
for (const field of fields) {
|
|
88
|
+
if (next[field.key] == null) {
|
|
89
|
+
next[field.key] = loadedExtend.value[field.key] ?? ''
|
|
90
|
+
changed = true
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (changed) extendValues.value = next
|
|
94
|
+
}, { immediate: true })
|
|
66
95
|
|
|
67
96
|
const loading = ref(false)
|
|
68
97
|
const saving = ref(false)
|
|
@@ -109,8 +138,6 @@ const aiPreviewLabels = computed(() => {
|
|
|
109
138
|
default: return { before: t('editor.aiPreview.before'), after: t('editor.aiPreview.after') }
|
|
110
139
|
}
|
|
111
140
|
})
|
|
112
|
-
/** 관리자 전용 — 비속어 필터 건너뛰기 (테스트/공지 등 의도적 작성용) */
|
|
113
|
-
const adminSkipModeration = ref(false)
|
|
114
141
|
/** 공지 지정 — 게시된 글을 수정할 때 보드 매니저에게만 보인다. 새 글은 게시 후 상세에서 지정한다. */
|
|
115
142
|
const pinned = ref(false)
|
|
116
143
|
const loadedPinned = ref(false)
|
|
@@ -952,6 +979,8 @@ async function loadPost(id: string) {
|
|
|
952
979
|
return
|
|
953
980
|
}
|
|
954
981
|
title.value = post.title
|
|
982
|
+
loadedExtend.value = readExtend(post.extend)
|
|
983
|
+
extendValues.value = storedExtend(post.extend)
|
|
955
984
|
pinned.value = loadedPinned.value = post.pinned === true
|
|
956
985
|
loadedPublished.value = post.isPublished === true
|
|
957
986
|
tags.value = post.tags ?? []
|
|
@@ -990,6 +1019,8 @@ watch(
|
|
|
990
1019
|
if (id) void loadPost(id)
|
|
991
1020
|
else {
|
|
992
1021
|
loading.value = false
|
|
1022
|
+
loadedExtend.value = {}
|
|
1023
|
+
extendValues.value = blankExtend()
|
|
993
1024
|
attachmentNamespace.value = createPostId()
|
|
994
1025
|
coverSlots.value = []
|
|
995
1026
|
void ensureSettings().catch(() => {})
|
|
@@ -1045,7 +1076,7 @@ async function handleSubmit() {
|
|
|
1045
1076
|
try {
|
|
1046
1077
|
const plain = plainTextFromHtml(content.value)
|
|
1047
1078
|
const moderationText = imageBoard ? plain : `${title.value}\n${plain}`
|
|
1048
|
-
const moderation = await checkText(moderationText
|
|
1079
|
+
const moderation = await checkText(moderationText)
|
|
1049
1080
|
if (moderation.flagged) {
|
|
1050
1081
|
error.value = moderation.reason || t('editor.errors.moderationBlocked')
|
|
1051
1082
|
return
|
|
@@ -1082,6 +1113,7 @@ async function handleSubmit() {
|
|
|
1082
1113
|
tags: tags.value.map(t => t.trim()).filter(Boolean),
|
|
1083
1114
|
// 이미지 보드: 파일 첨부 없음 (커버는 본문 앞 img 로 저장)
|
|
1084
1115
|
attachments: imageBoard ? [] : attachments.value,
|
|
1116
|
+
extend: extendForFields(extendValues.value, customFields.value),
|
|
1085
1117
|
...(translations ? { translations } : {}),
|
|
1086
1118
|
}
|
|
1087
1119
|
|
|
@@ -1442,21 +1474,26 @@ async function handleSubmit() {
|
|
|
1442
1474
|
</p>
|
|
1443
1475
|
|
|
1444
1476
|
<div
|
|
1445
|
-
v-if="
|
|
1477
|
+
v-if="canPin"
|
|
1446
1478
|
class="flex flex-wrap items-center gap-x-6 gap-y-2"
|
|
1447
1479
|
>
|
|
1448
1480
|
<USwitch
|
|
1449
|
-
v-if="canPin"
|
|
1450
1481
|
v-model="pinned"
|
|
1451
1482
|
size="sm"
|
|
1452
1483
|
:label="t('editor.pinNotice')"
|
|
1453
1484
|
/>
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1485
|
+
</div>
|
|
1486
|
+
|
|
1487
|
+
<div v-if="customFields.length" class="flex flex-col gap-3">
|
|
1488
|
+
<label v-for="field in customFields" :key="field.key" class="flex flex-col gap-1">
|
|
1489
|
+
<span class="text-sm font-medium">{{ field.label }}</span>
|
|
1490
|
+
<UInput
|
|
1491
|
+
v-model="extendValues[field.key]"
|
|
1492
|
+
:placeholder="field.placeholder"
|
|
1493
|
+
:maxlength="CUSTOM_FIELD_VALUE_MAX"
|
|
1494
|
+
autocomplete="off"
|
|
1495
|
+
/>
|
|
1496
|
+
</label>
|
|
1460
1497
|
</div>
|
|
1461
1498
|
|
|
1462
1499
|
<div class="flex gap-2">
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed, onMounted, ref, watch } from 'vue'
|
|
3
|
-
import { boardCopyIsTranslated, boardTranslationLocales, MEMI_BOARD_LOCALES, slugify, useMemiBoardAuth, useMemiBoardI18n, useMemiBoardSettings, useMemiBoardUsers } from 'memi-board/runtime'
|
|
3
|
+
import { BOARD_NAME_MAX, boardCopyIsTranslated, boardTranslationLocales, MEMI_BOARD_LOCALES, slugify, useMemiBoardAuth, useMemiBoardI18n, useMemiBoardSettings, useMemiBoardUsers } from 'memi-board/runtime'
|
|
4
4
|
import type { BoardCategory, BoardEditorType, BoardListView, BoardVisibility, BoardWriteRole } from 'memi-board/runtime'
|
|
5
|
+
import MemiBoardCustomFieldEditor from './CustomFieldEditor.vue'
|
|
5
6
|
import MemiBoardOptionCards from './OptionCards.vue'
|
|
6
7
|
import MemiBoardOrphanCleanup from './OrphanCleanup.vue'
|
|
7
8
|
|
|
@@ -10,6 +11,8 @@ const props = withDefaults(defineProps<{
|
|
|
10
11
|
authorized?: boolean
|
|
11
12
|
/** 카테고리 페이지 기본 경로. 기본 `/board` → `/board/{id}` */
|
|
12
13
|
categoryBase?: string
|
|
14
|
+
/** 목록에서 게시판 설정으로 이동할 경로. 기본 `/board-settings` → `/board-settings/{id}` */
|
|
15
|
+
settingsBase?: string
|
|
13
16
|
/** 지정하면 해당 카테고리 하나만 편집하고 추가·삭제·순서·위험 영역을 숨긴다. */
|
|
14
17
|
categoryId?: string
|
|
15
18
|
/** 호스트 서버에서 게시판 전체 데이터를 삭제하는 관리자 전용 작업. */
|
|
@@ -22,6 +25,7 @@ const props = withDefaults(defineProps<{
|
|
|
22
25
|
}>(), {
|
|
23
26
|
authorized: false,
|
|
24
27
|
categoryBase: '/board',
|
|
28
|
+
settingsBase: '/board-settings',
|
|
25
29
|
})
|
|
26
30
|
|
|
27
31
|
const emit = defineEmits<{
|
|
@@ -93,6 +97,8 @@ const pending = computed(() => !mounted.value || rolePending.value || settingsPe
|
|
|
93
97
|
|
|
94
98
|
const draft = ref<BoardCategory[]>([])
|
|
95
99
|
const newLabel = ref('')
|
|
100
|
+
const adding = ref(false)
|
|
101
|
+
const newIdPreview = computed(() => slugify(newLabel.value.trim()))
|
|
96
102
|
const savingId = ref<string | null>(null)
|
|
97
103
|
const translatingId = ref<string | null>(null)
|
|
98
104
|
const translatedDraftIds = ref(new Set<string>())
|
|
@@ -121,18 +127,19 @@ watch([categories, settingsPending], ([list, loading]) => {
|
|
|
121
127
|
autoTranslate: category.autoTranslate === true,
|
|
122
128
|
autoTranslatePosts: category.autoTranslatePosts === true,
|
|
123
129
|
translationLocales: [...(category.translationLocales ?? [])],
|
|
130
|
+
customFields: (category.customFields ?? []).map(field => ({ ...field })),
|
|
124
131
|
}))
|
|
125
132
|
}
|
|
126
133
|
}, { immediate: true })
|
|
127
134
|
|
|
128
|
-
function addCategory() {
|
|
129
|
-
const label = newLabel.value.trim()
|
|
130
|
-
|
|
131
|
-
|
|
135
|
+
async function addCategory() {
|
|
136
|
+
const label = newLabel.value.trim().slice(0, BOARD_NAME_MAX)
|
|
137
|
+
const base = slugify(label)
|
|
138
|
+
if (!label || !base || adding.value) return
|
|
132
139
|
let id = base
|
|
133
140
|
let suffix = 2
|
|
134
141
|
while (draft.value.some(category => category.id === id)) id = `${base}-${suffix++}`
|
|
135
|
-
|
|
142
|
+
const board: BoardCategory = {
|
|
136
143
|
id,
|
|
137
144
|
label,
|
|
138
145
|
description: '',
|
|
@@ -146,8 +153,23 @@ function addCategory() {
|
|
|
146
153
|
locale: '',
|
|
147
154
|
autoTranslatePosts: false,
|
|
148
155
|
translationLocales: [],
|
|
149
|
-
|
|
150
|
-
|
|
156
|
+
customFields: [],
|
|
157
|
+
order: draft.value.length,
|
|
158
|
+
}
|
|
159
|
+
adding.value = true
|
|
160
|
+
error.value = ''
|
|
161
|
+
try {
|
|
162
|
+
await saveCategory(board, draft.value.length)
|
|
163
|
+
draft.value.push(board)
|
|
164
|
+
newLabel.value = ''
|
|
165
|
+
savedId.value = board.id
|
|
166
|
+
}
|
|
167
|
+
catch (cause) {
|
|
168
|
+
error.value = cause instanceof Error ? cause.message : t('settings.error.saveFailed')
|
|
169
|
+
}
|
|
170
|
+
finally {
|
|
171
|
+
adding.value = false
|
|
172
|
+
}
|
|
151
173
|
}
|
|
152
174
|
|
|
153
175
|
async function moveCategory(index: number, offset: -1 | 1) {
|
|
@@ -193,6 +215,87 @@ function categoryTo(id: string) {
|
|
|
193
215
|
return `${props.categoryBase.replace(/\/$/, '')}/${encodeURIComponent(id)}`
|
|
194
216
|
}
|
|
195
217
|
|
|
218
|
+
function settingsTo(id: string) {
|
|
219
|
+
return `${props.settingsBase.replace(/\/$/, '')}/${encodeURIComponent(id)}`
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const listQuery = ref('')
|
|
223
|
+
const listSort = ref<'label' | 'description' | 'id'>('label')
|
|
224
|
+
const visibilityFilter = ref<'all' | BoardVisibility>('all')
|
|
225
|
+
const visibilityFilterItems = computed(() => [
|
|
226
|
+
{ label: t('settings.list.filterAll'), value: 'all' as const },
|
|
227
|
+
{ label: t('settings.visibility.options.public.label'), value: 'public' as const },
|
|
228
|
+
{ label: t('settings.visibility.options.hidden.label'), value: 'hidden' as const },
|
|
229
|
+
])
|
|
230
|
+
const listSortItems = computed(() => [
|
|
231
|
+
{ label: t('settings.list.sortName'), value: 'label' as const },
|
|
232
|
+
{ label: t('settings.list.sortDescription'), value: 'description' as const },
|
|
233
|
+
{ label: t('settings.list.sortId'), value: 'id' as const },
|
|
234
|
+
])
|
|
235
|
+
const filteredDraft = computed(() => {
|
|
236
|
+
const query = listQuery.value.trim().toLowerCase()
|
|
237
|
+
const rows = draft.value.filter((category) => {
|
|
238
|
+
if (visibilityFilter.value !== 'all' && (category.visibility ?? 'public') !== visibilityFilter.value) return false
|
|
239
|
+
if (!query) return true
|
|
240
|
+
const haystack = [category.id, category.label, category.description].filter(Boolean).join('\n').toLowerCase()
|
|
241
|
+
return haystack.includes(query)
|
|
242
|
+
})
|
|
243
|
+
const sort = listSort.value
|
|
244
|
+
const textOf = (category: BoardCategory) => {
|
|
245
|
+
if (sort === 'description') return category.description?.trim() ?? ''
|
|
246
|
+
if (sort === 'id') return category.id
|
|
247
|
+
return category.label.trim() || category.id
|
|
248
|
+
}
|
|
249
|
+
return rows.sort((a, b) => {
|
|
250
|
+
const left = textOf(a)
|
|
251
|
+
const right = textOf(b)
|
|
252
|
+
if (!left && right) return 1
|
|
253
|
+
if (left && !right) return -1
|
|
254
|
+
return left.localeCompare(right, 'ko', { sensitivity: 'base' }) || a.id.localeCompare(b.id, 'ko')
|
|
255
|
+
})
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
function draftIndex(id: string) {
|
|
259
|
+
return draft.value.findIndex(category => category.id === id)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function roleLabel(role: BoardWriteRole | undefined) {
|
|
263
|
+
return t(`settings.writeRole.options.${role ?? 'user'}`)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function boardFacts(category: BoardCategory) {
|
|
267
|
+
const listView = category.listView ?? 'default'
|
|
268
|
+
const listViewLabel = listView === 'dense'
|
|
269
|
+
? t('settings.listView.options.dense')
|
|
270
|
+
: listView === 'image'
|
|
271
|
+
? t('common.label.image')
|
|
272
|
+
: listView === 'video'
|
|
273
|
+
? t('settings.listView.options.video')
|
|
274
|
+
: t('settings.option.default')
|
|
275
|
+
const editor = category.editorType ?? (listView === 'image' ? 'image' : 'default')
|
|
276
|
+
const fieldCount = category.customFields?.length ?? 0
|
|
277
|
+
return [
|
|
278
|
+
{
|
|
279
|
+
label: t('settings.visibility.label'),
|
|
280
|
+
value: (category.visibility ?? 'public') === 'hidden'
|
|
281
|
+
? t('settings.visibility.options.hidden.label')
|
|
282
|
+
: t('settings.visibility.options.public.label'),
|
|
283
|
+
},
|
|
284
|
+
{ label: t('settings.listView.label'), value: listViewLabel },
|
|
285
|
+
{ label: t('settings.editorType.label'), value: editor === 'image' ? t('common.label.image') : t('settings.option.default') },
|
|
286
|
+
{ label: t('settings.writeRole.label'), value: roleLabel(category.writeRole) },
|
|
287
|
+
{
|
|
288
|
+
label: t('settings.commentWriteRole.label'),
|
|
289
|
+
value: category.visibility === 'hidden' ? t('settings.commentWriteRole.hiddenFixed') : roleLabel(category.commentWriteRole),
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
label: t('settings.customFields.label'),
|
|
293
|
+
value: fieldCount ? t('settings.list.fieldCount', { count: fieldCount }) : t('settings.list.none'),
|
|
294
|
+
},
|
|
295
|
+
{ label: t('settings.locale.label'), value: category.locale || t('settings.list.localeAuto') },
|
|
296
|
+
]
|
|
297
|
+
}
|
|
298
|
+
|
|
196
299
|
async function translateCopy(category: BoardCategory) {
|
|
197
300
|
if (!translationEnabled.value || !category.label.trim() || translatingId.value || savingId.value) return
|
|
198
301
|
const snapshot = { ...category }
|
|
@@ -265,7 +368,7 @@ const bulk = ref({
|
|
|
265
368
|
const bulkConfirmOpen = ref(false)
|
|
266
369
|
const bulkRunning = ref<'apply' | 'translate' | null>(null)
|
|
267
370
|
const bulkNotice = ref('')
|
|
268
|
-
const showBulk = computed(() =>
|
|
371
|
+
const showBulk = computed(() => false)
|
|
269
372
|
const bulkHasChanges = computed(() => bulk.value.translationLocalesOn || bulk.value.autoTranslateOn
|
|
270
373
|
|| bulk.value.autoTranslatePostsOn || bulk.value.localeOn)
|
|
271
374
|
const untranslatedBoards = computed(() => categories.value.filter(board => board.label.trim() && !boardCopyIsTranslated(board)))
|
|
@@ -450,6 +553,82 @@ async function runDeleteAll() {
|
|
|
450
553
|
</template>
|
|
451
554
|
</UModal>
|
|
452
555
|
|
|
556
|
+
<div v-if="!categoryId" class="flex flex-col gap-3">
|
|
557
|
+
<div class="flex flex-col gap-2">
|
|
558
|
+
<div class="flex gap-2">
|
|
559
|
+
<UInput v-model="newLabel" class="flex-1" :placeholder="t('settings.newCategoryPlaceholder')" :maxlength="BOARD_NAME_MAX" :disabled="adding" @keyup.enter="addCategory" />
|
|
560
|
+
<UButton :label="t('common.action.add')" icon="i-lucide-plus" color="neutral" variant="outline" :loading="adding" @click="addCategory" />
|
|
561
|
+
</div>
|
|
562
|
+
<p class="text-xs text-muted">{{ t('settings.newCategoryHelp') }}</p>
|
|
563
|
+
<p v-if="newIdPreview" class="text-xs text-muted">{{ t('settings.newCategoryUrl', { path: `${categoryBase.replace(/\/$/, '')}/${newIdPreview}` }) }}</p>
|
|
564
|
+
</div>
|
|
565
|
+
<div class="flex items-center gap-2">
|
|
566
|
+
<UInput
|
|
567
|
+
v-model="listQuery"
|
|
568
|
+
icon="i-lucide-search"
|
|
569
|
+
class="min-w-0 flex-1"
|
|
570
|
+
:placeholder="t('settings.list.filterPlaceholder')"
|
|
571
|
+
:aria-label="t('settings.list.filterPlaceholder')"
|
|
572
|
+
/>
|
|
573
|
+
<USelect
|
|
574
|
+
v-model="listSort"
|
|
575
|
+
:items="listSortItems"
|
|
576
|
+
value-key="value"
|
|
577
|
+
class="w-24 shrink-0"
|
|
578
|
+
:aria-label="t('settings.list.sort')"
|
|
579
|
+
/>
|
|
580
|
+
<UFieldGroup :aria-label="t('settings.visibility.label')" class="shrink-0">
|
|
581
|
+
<UButton
|
|
582
|
+
v-for="item in visibilityFilterItems"
|
|
583
|
+
:key="item.value"
|
|
584
|
+
:label="item.label"
|
|
585
|
+
size="sm"
|
|
586
|
+
:color="visibilityFilter === item.value ? 'primary' : 'neutral'"
|
|
587
|
+
:variant="visibilityFilter === item.value ? 'solid' : 'outline'"
|
|
588
|
+
@click="visibilityFilter = item.value"
|
|
589
|
+
/>
|
|
590
|
+
</UFieldGroup>
|
|
591
|
+
</div>
|
|
592
|
+
<p v-if="!filteredDraft.length" class="text-sm text-muted">{{ t('settings.list.empty') }}</p>
|
|
593
|
+
<UPageGrid class="gap-3">
|
|
594
|
+
<article
|
|
595
|
+
v-for="category in filteredDraft"
|
|
596
|
+
:key="category.id"
|
|
597
|
+
class="flex flex-col gap-3 rounded-xl border border-default bg-default p-4"
|
|
598
|
+
>
|
|
599
|
+
<div class="flex items-start justify-between gap-3">
|
|
600
|
+
<NuxtLink :to="settingsTo(category.id)" class="min-w-0">
|
|
601
|
+
<p class="flex items-center gap-1 font-semibold text-highlighted">
|
|
602
|
+
{{ category.label || t('settings.noLabel') }}
|
|
603
|
+
<UIcon name="i-lucide-chevron-right" class="size-4 text-muted" />
|
|
604
|
+
</p>
|
|
605
|
+
<code class="mt-0.5 block text-xs text-muted">{{ category.id }}</code>
|
|
606
|
+
</NuxtLink>
|
|
607
|
+
<div class="flex shrink-0 gap-1">
|
|
608
|
+
<UButton
|
|
609
|
+
icon="i-lucide-trash-2"
|
|
610
|
+
color="error"
|
|
611
|
+
variant="ghost"
|
|
612
|
+
size="sm"
|
|
613
|
+
:aria-label="t('settings.action.deleteBoard')"
|
|
614
|
+
:loading="deletingId === category.id"
|
|
615
|
+
:disabled="deletingId !== null"
|
|
616
|
+
@click="removeCategory(draftIndex(category.id))"
|
|
617
|
+
/>
|
|
618
|
+
</div>
|
|
619
|
+
</div>
|
|
620
|
+
<p v-if="category.description" class="line-clamp-2 text-sm text-muted">{{ category.description }}</p>
|
|
621
|
+
<dl class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
|
|
622
|
+
<div v-for="fact in boardFacts(category)" :key="fact.label">
|
|
623
|
+
<dt class="text-xs text-muted">{{ fact.label }}</dt>
|
|
624
|
+
<dd class="mt-0.5 text-highlighted">{{ fact.value }}</dd>
|
|
625
|
+
</div>
|
|
626
|
+
</dl>
|
|
627
|
+
</article>
|
|
628
|
+
</UPageGrid>
|
|
629
|
+
</div>
|
|
630
|
+
|
|
631
|
+
<template v-if="categoryId">
|
|
453
632
|
<details
|
|
454
633
|
v-for="(category, index) in draft"
|
|
455
634
|
:key="category.id"
|
|
@@ -603,6 +782,7 @@ async function runDeleteAll() {
|
|
|
603
782
|
@update:model-value="savedId = null"
|
|
604
783
|
/>
|
|
605
784
|
</div>
|
|
785
|
+
<MemiBoardCustomFieldEditor v-model="category.customFields" @dirty="savedId = null" />
|
|
606
786
|
<div v-if="i18nEnabled" class="grid gap-2 sm:grid-cols-[9rem_1fr] sm:items-center">
|
|
607
787
|
<div><p class="text-sm font-medium">{{ t('settings.locale.label') }}</p><p class="text-xs text-muted">{{ t('settings.locale.help') }}</p></div>
|
|
608
788
|
<USelect
|
|
@@ -650,11 +830,8 @@ async function runDeleteAll() {
|
|
|
650
830
|
</div>
|
|
651
831
|
</div>
|
|
652
832
|
</details>
|
|
833
|
+
</template>
|
|
653
834
|
|
|
654
|
-
<div v-if="!categoryId" class="flex gap-2 border-t border-default pt-4">
|
|
655
|
-
<UInput v-model="newLabel" class="flex-1" :placeholder="t('settings.newCategoryPlaceholder')" @keyup.enter="addCategory" />
|
|
656
|
-
<UButton :label="t('common.action.add')" icon="i-lucide-plus" color="neutral" variant="outline" @click="addCategory" />
|
|
657
|
-
</div>
|
|
658
835
|
<UAlert v-if="error" color="error" variant="subtle" :description="error" />
|
|
659
836
|
<UAlert v-if="savedId" color="success" variant="subtle" :description="t('settings.savedCategory', { label: draft.find(item => item.id === savedId)?.label })" />
|
|
660
837
|
|
package/dist/storage.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { G as e, T as t, a as n, c as r, h as i, n as a } from "./commentImages-t4lu_hxD.js";
|
|
2
2
|
import { useFirebaseApp as o } from "vuefire";
|
|
3
3
|
import { getAuth as s } from "firebase/auth";
|
|
4
4
|
import { deleteObject as c, getDownloadURL as l, getStorage as u, ref as d, uploadBytes as f, uploadBytesResumable as p } from "firebase/storage";
|
|
5
5
|
//#region src/utils/compressImage.ts
|
|
6
|
-
async function m(e, { maxWidth: t = 400, quality:
|
|
6
|
+
async function m(e, { maxWidth: t = 400, quality: n = .8 } = {}, r) {
|
|
7
7
|
return new Promise((a, o) => {
|
|
8
8
|
let s = new Image(), c = URL.createObjectURL(e);
|
|
9
9
|
s.onload = () => {
|
|
@@ -12,12 +12,12 @@ async function m(e, { maxWidth: t = 400, quality: r = .8 } = {}, i) {
|
|
|
12
12
|
l.width = Math.round(s.naturalWidth * e), l.height = Math.round(s.naturalHeight * e);
|
|
13
13
|
let u = l.getContext("2d");
|
|
14
14
|
if (!u) {
|
|
15
|
-
o(Error(
|
|
15
|
+
o(Error(i(r, "storage.canvasUnavailable")));
|
|
16
16
|
return;
|
|
17
17
|
}
|
|
18
|
-
u.drawImage(s, 0, 0, l.width, l.height), l.toBlob((e) => e ? a(e) : o(Error(
|
|
18
|
+
u.drawImage(s, 0, 0, l.width, l.height), l.toBlob((e) => e ? a(e) : o(Error(i(r, "storage.compressFailed"))), "image/jpeg", n);
|
|
19
19
|
}, s.onerror = () => {
|
|
20
|
-
URL.revokeObjectURL(c), o(Error(
|
|
20
|
+
URL.revokeObjectURL(c), o(Error(i(r, "storage.loadFailed")));
|
|
21
21
|
}, s.src = c;
|
|
22
22
|
});
|
|
23
23
|
}
|
|
@@ -31,19 +31,19 @@ function _(e) {
|
|
|
31
31
|
var v = class extends Error {};
|
|
32
32
|
async function y(e, t) {
|
|
33
33
|
if (e.size <= 5242880) return e;
|
|
34
|
-
let
|
|
34
|
+
let n = await m(e, {
|
|
35
35
|
maxWidth: 2560,
|
|
36
36
|
quality: .85
|
|
37
37
|
}, t);
|
|
38
|
-
if (
|
|
38
|
+
if (n.size > 5242880 && (n = await m(e, {
|
|
39
39
|
maxWidth: 2048,
|
|
40
40
|
quality: .75
|
|
41
|
-
}, t)),
|
|
42
|
-
return
|
|
41
|
+
}, t)), n.size > 5242880) throw new v(i(t, "storage.tooLargeAfterCompress"));
|
|
42
|
+
return n;
|
|
43
43
|
}
|
|
44
44
|
async function b(e, t) {
|
|
45
|
-
if (e.size > 26214400) throw Error(
|
|
46
|
-
if (_(e)) throw Error(
|
|
45
|
+
if (e.size > 26214400) throw Error(i(t, "storage.sourceTooLarge", { max: g / 1024 / 1024 }));
|
|
46
|
+
if (_(e)) throw Error(i(t, "storage.heicUnsupported"));
|
|
47
47
|
if (e.size <= 5242880) return {
|
|
48
48
|
blob: e,
|
|
49
49
|
compressed: !1
|
|
@@ -54,34 +54,34 @@ async function b(e, t) {
|
|
|
54
54
|
compressed: !0
|
|
55
55
|
};
|
|
56
56
|
} catch (e) {
|
|
57
|
-
throw e instanceof v ? e : Error(
|
|
57
|
+
throw e instanceof v ? e : Error(i(t, "storage.cannotOptimize"));
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
60
|
function x() {
|
|
61
|
-
let
|
|
61
|
+
let i = () => t(), h = o(), { t: g, locale: _ } = r();
|
|
62
62
|
function v() {
|
|
63
63
|
let e = s(h).currentUser?.uid;
|
|
64
64
|
if (!e) throw Error("Sign in before uploading a file.");
|
|
65
65
|
return { ownerUid: e };
|
|
66
66
|
}
|
|
67
|
-
function y(
|
|
68
|
-
let o = u(h), s = `${e(
|
|
67
|
+
function y(t, r, a) {
|
|
68
|
+
let o = u(h), s = `${e(i(), r)}/attachments/${Date.now()}-${n(t.name)}`, c = d(o, s), f = p(c, t, {
|
|
69
69
|
customMetadata: v(),
|
|
70
|
-
contentType:
|
|
71
|
-
contentDisposition: `attachment; filename*=UTF-8''${encodeURIComponent(
|
|
70
|
+
contentType: t.type || "application/octet-stream",
|
|
71
|
+
contentDisposition: `attachment; filename*=UTF-8''${encodeURIComponent(t.name)}`
|
|
72
72
|
});
|
|
73
73
|
return {
|
|
74
|
-
promise: new Promise((e,
|
|
74
|
+
promise: new Promise((e, n) => {
|
|
75
75
|
f.on("state_changed", (e) => {
|
|
76
76
|
a && e.totalBytes > 0 && a(e.bytesTransferred / e.totalBytes);
|
|
77
|
-
},
|
|
78
|
-
let
|
|
77
|
+
}, n, async () => {
|
|
78
|
+
let n = await l(c);
|
|
79
79
|
e({
|
|
80
|
-
name:
|
|
81
|
-
url:
|
|
80
|
+
name: t.name,
|
|
81
|
+
url: n,
|
|
82
82
|
path: s,
|
|
83
|
-
size:
|
|
84
|
-
type:
|
|
83
|
+
size: t.size,
|
|
84
|
+
type: t.type
|
|
85
85
|
});
|
|
86
86
|
});
|
|
87
87
|
}),
|
|
@@ -92,21 +92,21 @@ function x() {
|
|
|
92
92
|
let t = u(h);
|
|
93
93
|
await c(d(t, e.path)).catch(() => {});
|
|
94
94
|
}
|
|
95
|
-
function S(
|
|
96
|
-
if (!
|
|
97
|
-
if (!/^[A-Za-z0-9]{10,128}$/.test(
|
|
95
|
+
function S(t, r, o, s) {
|
|
96
|
+
if (!a(t)) throw Error(g("commentForm.imageOnly"));
|
|
97
|
+
if (!/^[A-Za-z0-9]{10,128}$/.test(r) || !/^[A-Za-z0-9]{10,128}$/.test(o)) throw Error(g("commentForm.uploadFailed"));
|
|
98
98
|
let c = v(), f = u(h), y, x = !1;
|
|
99
99
|
return {
|
|
100
100
|
promise: (async () => {
|
|
101
|
-
let
|
|
101
|
+
let a = await b(t, _.value);
|
|
102
102
|
if (x) throw Error(g("commentForm.uploadFailed"));
|
|
103
|
-
let u =
|
|
103
|
+
let u = a.blob;
|
|
104
104
|
if (u.size >= 5242880 && (u = await m(u, {
|
|
105
105
|
maxWidth: 2048,
|
|
106
106
|
quality: .75
|
|
107
107
|
}, _.value)), x) throw Error(g("commentForm.uploadFailed"));
|
|
108
108
|
if (u.size >= 5242880) throw Error(g("storage.tooLargeAfterCompress"));
|
|
109
|
-
let h = (u.type ||
|
|
109
|
+
let h = (u.type || t.type || "image/jpeg").toLowerCase(), v = h === "image/png" ? "png" : h === "image/gif" ? "gif" : h === "image/webp" ? "webp" : "jpg", S = n(t.name.replace(/\.[^.]+$/, "") || "image", 40) || "image", C = `${e(i(), r)}/comment-images/${o}/${Date.now()}-${S}.${v}`, w = d(f, C);
|
|
110
110
|
if (y = p(w, u, {
|
|
111
111
|
customMetadata: c,
|
|
112
112
|
contentType: h
|
|
@@ -118,7 +118,7 @@ function x() {
|
|
|
118
118
|
});
|
|
119
119
|
let T = await l(w);
|
|
120
120
|
return {
|
|
121
|
-
name:
|
|
121
|
+
name: t.name || `image.${v}`,
|
|
122
122
|
url: T,
|
|
123
123
|
path: C,
|
|
124
124
|
size: u.size,
|
|
@@ -130,9 +130,9 @@ function x() {
|
|
|
130
130
|
}
|
|
131
131
|
};
|
|
132
132
|
}
|
|
133
|
-
async function C(
|
|
134
|
-
if (!
|
|
135
|
-
let a = await b(
|
|
133
|
+
async function C(t, r) {
|
|
134
|
+
if (!t.type.startsWith("image/")) throw Error(g("storage.imageOnly"));
|
|
135
|
+
let a = await b(t, _.value), o = u(h), s = (a.compressed ? "jpg" : t.name.split(".").pop() || t.type.split("/")[1] || "png").replace(/[^a-zA-Z0-9]/g, "").slice(0, 8) || "png", c = n(t.name.replace(/\.[^.]+$/, "") || "image", 40), p = `${Date.now()}-${c || "image"}`, y = r || `new-${Date.now()}`, x = e(i(), y), S = `${x}/images/${p}.${s}`, C = d(o, S);
|
|
136
136
|
await f(C, a.blob, {
|
|
137
137
|
customMetadata: v(),
|
|
138
138
|
contentType: a.blob.type || "image/jpeg"
|
|
@@ -156,7 +156,7 @@ function x() {
|
|
|
156
156
|
await Promise.all([c(d(t, e.originalPath)).catch(() => {}), c(d(t, e.thumbnailPath)).catch(() => {})]);
|
|
157
157
|
}
|
|
158
158
|
async function T(e, t) {
|
|
159
|
-
let
|
|
159
|
+
let n = u(h), r = `${i().usersCollection}/${e}/avatar.jpg`, a = d(n, r);
|
|
160
160
|
return await f(a, t, { contentType: t.type || "image/jpeg" }), l(a);
|
|
161
161
|
}
|
|
162
162
|
return {
|
|
@@ -169,6 +169,6 @@ function x() {
|
|
|
169
169
|
};
|
|
170
170
|
}
|
|
171
171
|
//#endregion
|
|
172
|
-
export { h as EDITOR_IMAGE_MAX_BYTES, g as EDITOR_IMAGE_SOURCE_MAX_BYTES, m as compressImage,
|
|
172
|
+
export { h as EDITOR_IMAGE_MAX_BYTES, g as EDITOR_IMAGE_SOURCE_MAX_BYTES, m as compressImage, a as isCommentImageFile, x as useMemiBoardStorage };
|
|
173
173
|
|
|
174
174
|
//# sourceMappingURL=storage.js.map
|
package/dist/types.d.ts
CHANGED
|
@@ -60,6 +60,11 @@ export interface PostModel {
|
|
|
60
60
|
/** 영상 목록 카드용 URL. 현재 YouTube 임베드 또는 영상 첨부파일. */
|
|
61
61
|
videoUrl?: string;
|
|
62
62
|
tags?: string[];
|
|
63
|
+
/**
|
|
64
|
+
* 보드 customFields 에 맞춘 추가 값. 키는 보드 설정을 따른다.
|
|
65
|
+
* 값은 입력한 그대로 저장하고 번역하지 않는다.
|
|
66
|
+
*/
|
|
67
|
+
extend?: Record<string, string>;
|
|
63
68
|
/**
|
|
64
69
|
* @deprecated 카테고리 개념 제거 — boardId 로 대체.
|
|
65
70
|
* 레거시 읽기 호환용으로만 남을 수 있음.
|
|
@@ -205,6 +210,18 @@ export type BoardEditorType = 'default' | 'image';
|
|
|
205
210
|
export type BoardWriteRole = 'user' | 'staff' | 'admin';
|
|
206
211
|
/** 게시판 공개 범위. hidden 이면 일반 목록·전체 필터에서 제외되고 글·댓글 listed=false. 댓글 쓰기는 담당 스태프만. */
|
|
207
212
|
export type BoardVisibility = 'public' | 'hidden';
|
|
213
|
+
/**
|
|
214
|
+
* 보드가 글의 extend 에 받는 추가 항목.
|
|
215
|
+
* label 은 입력한 그대로 보여 주고, 보드 이름·설명처럼 번역하지 않는다.
|
|
216
|
+
*/
|
|
217
|
+
export interface BoardCustomField {
|
|
218
|
+
/** extend 의 키. 영문 소문자로 시작하고 영문·숫자·밑줄만, 32자 이하. */
|
|
219
|
+
key: string;
|
|
220
|
+
/** 글쓰기·상세에 보이는 이름. 번역하지 않는다. */
|
|
221
|
+
label: string;
|
|
222
|
+
/** 글쓰기 입력칸의 플레이스홀더. 비우면 보여 주지 않는다. 번역하지 않는다. */
|
|
223
|
+
placeholder?: string;
|
|
224
|
+
}
|
|
208
225
|
/**
|
|
209
226
|
* 보드 1개 메타 (= 예전 BoardCategory).
|
|
210
227
|
* id 가 곧 boardId — memiBoardSettings/{id} 에 저장.
|
|
@@ -253,6 +270,11 @@ export interface BoardModel {
|
|
|
253
270
|
* 비어 있거나 없으면 지원 언어 전체. 글쓴 언어(원문)는 번역하지 않는다.
|
|
254
271
|
*/
|
|
255
272
|
translationLocales?: MemiBoardLocale[];
|
|
273
|
+
/**
|
|
274
|
+
* 글의 extend 에 넣을 수 있는 필드. 최대 8개.
|
|
275
|
+
* 키는 보드 안에서 고유하고, 이름은 번역하지 않는다.
|
|
276
|
+
*/
|
|
277
|
+
customFields?: BoardCustomField[];
|
|
256
278
|
/** 보드 목록 정렬 */
|
|
257
279
|
order?: number;
|
|
258
280
|
}
|
|
@@ -324,7 +346,7 @@ export interface BoardLikeModel {
|
|
|
324
346
|
commentId?: string;
|
|
325
347
|
}
|
|
326
348
|
/** 검열이 어느 단계에서 끝났는지 (호스트/로깅용, UI 노출 없음) */
|
|
327
|
-
export type ModerationVia = 'empty' | 'disabled' | 'local' | 'ai' | 'restricted' | 'ai-error-allow' | 'ai-error-block'
|
|
349
|
+
export type ModerationVia = 'empty' | 'disabled' | 'local' | 'ai' | 'restricted' | 'ai-error-allow' | 'ai-error-block';
|
|
328
350
|
export interface ModerationResult {
|
|
329
351
|
flagged: boolean;
|
|
330
352
|
category: 'none' | 'abuse' | 'spam' | 'adult' | 'violence' | 'other';
|