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.
Files changed (49) hide show
  1. package/README.md +4 -6
  2. package/dist/{ar-3hd6xh6U.js → ar-DBv_xFzj.js} +3 -2
  3. package/dist/ar-DBv_xFzj.js.map +1 -0
  4. package/dist/{commentImages-4IOk2u0O.js → commentImages-t4lu_hxD.js} +130 -99
  5. package/dist/commentImages-t4lu_hxD.js.map +1 -0
  6. package/dist/composables/useMemiBoardModeration.d.ts +1 -3
  7. package/dist/composables/useMemiBoardPosts.d.ts +6 -0
  8. package/dist/{de-DXtmk-4H.js → de-DiqhMTJO.js} +3 -2
  9. package/dist/de-DiqhMTJO.js.map +1 -0
  10. package/dist/{en-CyZH4Ct1.js → en-hMF_prdm.js} +33 -3
  11. package/dist/en-hMF_prdm.js.map +1 -0
  12. package/dist/{es-BtRbHicT.js → es-RfbaViQj.js} +3 -2
  13. package/dist/es-RfbaViQj.js.map +1 -0
  14. package/dist/{fr-vnizG3rD.js → fr-BlRWQtSh.js} +3 -2
  15. package/dist/fr-BlRWQtSh.js.map +1 -0
  16. package/dist/{id-Cf8yw8n_.js → id-C0-e3Gwc.js} +3 -2
  17. package/dist/id-C0-e3Gwc.js.map +1 -0
  18. package/dist/index.d.ts +3 -2
  19. package/dist/index.js +1172 -1059
  20. package/dist/index.js.map +1 -1
  21. package/dist/{ja-D-Nrtjk-.js → ja-q1DilxZV.js} +3 -2
  22. package/dist/ja-q1DilxZV.js.map +1 -0
  23. package/dist/{pt-CSsshr7u.js → pt-sB9letRc.js} +3 -2
  24. package/dist/pt-sB9letRc.js.map +1 -0
  25. package/dist/runtime/components/Attachments.vue +82 -22
  26. package/dist/runtime/components/CategorySettings.vue +4 -0
  27. package/dist/runtime/components/CustomFieldEditor.vue +86 -0
  28. package/dist/runtime/components/Detail.vue +15 -1
  29. package/dist/runtime/components/Editor.vue +49 -12
  30. package/dist/runtime/components/Settings.vue +190 -13
  31. package/dist/storage.js +36 -36
  32. package/dist/types.d.ts +23 -1
  33. package/dist/utils/customFields.d.ts +33 -0
  34. package/dist/utils/slugify.d.ts +2 -0
  35. package/dist/{zh-BzrsqVN2.js → zh-Dag8WCjt.js} +3 -2
  36. package/dist/zh-Dag8WCjt.js.map +1 -0
  37. package/docs/firebase-setup.md +64 -0
  38. package/docs/host-integration.md +4 -0
  39. package/package.json +1 -1
  40. package/dist/ar-3hd6xh6U.js.map +0 -1
  41. package/dist/commentImages-4IOk2u0O.js.map +0 -1
  42. package/dist/de-DXtmk-4H.js.map +0 -1
  43. package/dist/en-CyZH4Ct1.js.map +0 -1
  44. package/dist/es-BtRbHicT.js.map +0 -1
  45. package/dist/fr-vnizG3rD.js.map +0 -1
  46. package/dist/id-Cf8yw8n_.js.map +0 -1
  47. package/dist/ja-D-Nrtjk-.js.map +0 -1
  48. package/dist/pt-CSsshr7u.js.map +0 -1
  49. 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, isAdmin, isWriteRestricted, restrictedMessage, canManageBoard } = useMemiBoardAuth()
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, { skipFilter: isAdmin.value && adminSkipModeration.value })
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="isAdmin || canPin"
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
- <USwitch
1455
- v-if="isAdmin"
1456
- v-model="adminSkipModeration"
1457
- size="sm"
1458
- :label="t('editor.skipModeration')"
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
- if (!label) return
131
- const base = slugify(label) || `board-${Date.now()}`
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
- draft.value.push({
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
- newLabel.value = ''
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(() => !props.categoryId && draft.value.length > 0 && (translationEnabled.value || i18nEnabled.value))
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 { W as e, i as t, m as n, n as r, s as i, w as a } from "./commentImages-4IOk2u0O.js";
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: r = .8 } = {}, i) {
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(n(i, "storage.canvasUnavailable")));
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(n(i, "storage.compressFailed"))), "image/jpeg", r);
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(n(i, "storage.loadFailed")));
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 r = await m(e, {
34
+ let n = await m(e, {
35
35
  maxWidth: 2560,
36
36
  quality: .85
37
37
  }, t);
38
- if (r.size > 5242880 && (r = await m(e, {
38
+ if (n.size > 5242880 && (n = await m(e, {
39
39
  maxWidth: 2048,
40
40
  quality: .75
41
- }, t)), r.size > 5242880) throw new v(n(t, "storage.tooLargeAfterCompress"));
42
- return r;
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(n(t, "storage.sourceTooLarge", { max: g / 1024 / 1024 }));
46
- if (_(e)) throw Error(n(t, "storage.heicUnsupported"));
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(n(t, "storage.cannotOptimize"));
57
+ throw e instanceof v ? e : Error(i(t, "storage.cannotOptimize"));
58
58
  }
59
59
  }
60
60
  function x() {
61
- let n = () => a(), h = o(), { t: g, locale: _ } = i();
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(r, i, a) {
68
- let o = u(h), s = `${e(n(), i)}/attachments/${Date.now()}-${t(r.name)}`, c = d(o, s), f = p(c, r, {
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: r.type || "application/octet-stream",
71
- contentDisposition: `attachment; filename*=UTF-8''${encodeURIComponent(r.name)}`
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, t) => {
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
- }, t, async () => {
78
- let t = await l(c);
77
+ }, n, async () => {
78
+ let n = await l(c);
79
79
  e({
80
- name: r.name,
81
- url: t,
80
+ name: t.name,
81
+ url: n,
82
82
  path: s,
83
- size: r.size,
84
- type: r.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(i, a, o, s) {
96
- if (!r(i)) throw Error(g("commentForm.imageOnly"));
97
- if (!/^[A-Za-z0-9]{10,128}$/.test(a) || !/^[A-Za-z0-9]{10,128}$/.test(o)) throw Error(g("commentForm.uploadFailed"));
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 r = await b(i, _.value);
101
+ let a = await b(t, _.value);
102
102
  if (x) throw Error(g("commentForm.uploadFailed"));
103
- let u = r.blob;
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 || i.type || "image/jpeg").toLowerCase(), v = h === "image/png" ? "png" : h === "image/gif" ? "gif" : h === "image/webp" ? "webp" : "jpg", S = t(i.name.replace(/\.[^.]+$/, "") || "image", 40) || "image", C = `${e(n(), a)}/comment-images/${o}/${Date.now()}-${S}.${v}`, w = d(f, C);
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: i.name || `image.${v}`,
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(r, i) {
134
- if (!r.type.startsWith("image/")) throw Error(g("storage.imageOnly"));
135
- let a = await b(r, _.value), o = u(h), s = (a.compressed ? "jpg" : r.name.split(".").pop() || r.type.split("/")[1] || "png").replace(/[^a-zA-Z0-9]/g, "").slice(0, 8) || "png", c = t(r.name.replace(/\.[^.]+$/, "") || "image", 40), p = `${Date.now()}-${c || "image"}`, y = i || `new-${Date.now()}`, x = e(n(), y), S = `${x}/images/${p}.${s}`, C = d(o, S);
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 r = u(h), i = `${n().usersCollection}/${e}/avatar.jpg`, a = d(r, i);
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, r as isCommentImageFile, x as useMemiBoardStorage };
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' | 'admin-override';
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';