memi-board 0.30.2 → 0.32.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 +12 -4
- package/dist/composables/useMemiBoardWritingAssistant.d.ts +13 -0
- package/dist/{heic2any-BHVEj2CD.js → heic2any-BWRKffWN.js} +2 -2
- package/dist/{heic2any-BHVEj2CD.js.map → heic2any-BWRKffWN.js.map} +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +354 -266
- package/dist/index.js.map +1 -1
- package/dist/runtime/components/Editor.vue +297 -18
- package/dist/runtime/components/Settings.vue +20 -3
- package/dist/{slugify-CDMIDDCN.js → slugify-B5XHr4SZ.js} +2 -2
- package/dist/{slugify-CDMIDDCN.js.map → slugify-B5XHr4SZ.js.map} +1 -1
- package/dist/storage.js +2 -2
- package/dist/types.d.ts +4 -0
- package/dist/utils/deletePostCascade.d.ts +5 -0
- package/dist/utils/moderation-strike.d.ts +2 -2
- package/docs/firestore.rules.example +21 -1
- package/package.json +1 -1
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { ref, computed, watch } from 'vue'
|
|
10
10
|
import Youtube from '@tiptap/extension-youtube'
|
|
11
11
|
import type { Attachment, EditorImageEntry } from 'memi-board/runtime'
|
|
12
|
+
import type { WritingAssistantAction } from 'memi-board/runtime'
|
|
12
13
|
import {
|
|
13
14
|
useMemiBoardAuth,
|
|
14
15
|
useMemiBoardPosts,
|
|
@@ -17,6 +18,7 @@ import {
|
|
|
17
18
|
hasBodyImage,
|
|
18
19
|
hasBodyText,
|
|
19
20
|
plainTextFromHtml,
|
|
21
|
+
useMemiBoardWritingAssistant,
|
|
20
22
|
} from 'memi-board/runtime'
|
|
21
23
|
import { useMemiBoardStorage, EDITOR_IMAGE_SOURCE_MAX_BYTES } from 'memi-board/storage'
|
|
22
24
|
import MemiBoardAttachments from './Attachments.vue'
|
|
@@ -37,6 +39,7 @@ const { createPostId, getPost, createPost, updatePost } = useMemiBoardPosts(reso
|
|
|
37
39
|
const { checkText } = useMemiBoardModeration()
|
|
38
40
|
const { getBoard, ensureSettings } = useMemiBoardSettings()
|
|
39
41
|
const { uploadEditorImage } = useMemiBoardStorage()
|
|
42
|
+
const { assist } = useMemiBoardWritingAssistant()
|
|
40
43
|
|
|
41
44
|
const title = ref('')
|
|
42
45
|
/** UEditor content-type=html (shineb 와 동일) */
|
|
@@ -54,6 +57,20 @@ const submitHint = ref('')
|
|
|
54
57
|
const error = ref('')
|
|
55
58
|
const imageUploading = ref(false)
|
|
56
59
|
const imageUploadError = ref('')
|
|
60
|
+
const imageDialogOpen = ref(false)
|
|
61
|
+
const imageDialogEditor = ref<any | null>(null)
|
|
62
|
+
const externalImageUrl = ref('')
|
|
63
|
+
const externalImageChecking = ref(false)
|
|
64
|
+
const externalImageCandidate = computed(() => {
|
|
65
|
+
try {
|
|
66
|
+
return normalizeExternalImageUrl(externalImageUrl.value)
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return ''
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
const aiRunning = ref(false)
|
|
73
|
+
const aiError = ref('')
|
|
57
74
|
|
|
58
75
|
const attachmentNamespace = ref(props.postId ?? createPostId())
|
|
59
76
|
const uploadedEditorImages = ref<EditorImageEntry[]>([])
|
|
@@ -147,6 +164,87 @@ function appendCoverUrls(urls: string[]) {
|
|
|
147
164
|
}
|
|
148
165
|
}
|
|
149
166
|
|
|
167
|
+
function normalizeExternalImageUrl(value: string): string {
|
|
168
|
+
const raw = value.trim()
|
|
169
|
+
if (!raw) throw new Error('이미지 주소를 입력해 주세요.')
|
|
170
|
+
let url: URL
|
|
171
|
+
try {
|
|
172
|
+
url = new URL(raw)
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
throw new Error('올바른 이미지 주소를 입력해 주세요.')
|
|
176
|
+
}
|
|
177
|
+
if (url.protocol !== 'https:') {
|
|
178
|
+
throw new Error('HTTPS 이미지 주소만 사용할 수 있습니다.')
|
|
179
|
+
}
|
|
180
|
+
if (url.username || url.password) {
|
|
181
|
+
throw new Error('로그인 정보가 포함된 주소는 사용할 수 없습니다.')
|
|
182
|
+
}
|
|
183
|
+
return url.toString()
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function verifyExternalImage(url: string): Promise<string> {
|
|
187
|
+
return new Promise((resolve, reject) => {
|
|
188
|
+
const image = new Image()
|
|
189
|
+
const timeout = window.setTimeout(() => {
|
|
190
|
+
image.src = ''
|
|
191
|
+
reject(new Error('이미지를 불러오는 데 시간이 너무 오래 걸립니다.'))
|
|
192
|
+
}, 10_000)
|
|
193
|
+
image.onload = () => {
|
|
194
|
+
window.clearTimeout(timeout)
|
|
195
|
+
if (!image.naturalWidth || !image.naturalHeight) {
|
|
196
|
+
reject(new Error('표시할 수 없는 이미지입니다.'))
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
resolve(url)
|
|
200
|
+
}
|
|
201
|
+
image.onerror = () => {
|
|
202
|
+
window.clearTimeout(timeout)
|
|
203
|
+
reject(new Error('이미지를 불러올 수 없습니다. 주소나 외부 링크 허용 여부를 확인해 주세요.'))
|
|
204
|
+
}
|
|
205
|
+
image.referrerPolicy = 'no-referrer'
|
|
206
|
+
image.src = url
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function openImageDialog(editor?: any) {
|
|
211
|
+
imageDialogEditor.value = editor ?? resolveEditor()
|
|
212
|
+
externalImageUrl.value = ''
|
|
213
|
+
imageUploadError.value = ''
|
|
214
|
+
imageDialogOpen.value = true
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function chooseImageFileFromDialog() {
|
|
218
|
+
const editor = imageDialogEditor.value ?? resolveEditor()
|
|
219
|
+
imageDialogOpen.value = false
|
|
220
|
+
if (isImageListView.value) pickCoverImages()
|
|
221
|
+
else if (editor) pickAndUploadImage(editor)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function addExternalImage() {
|
|
225
|
+
if (externalImageChecking.value) return
|
|
226
|
+
imageUploadError.value = ''
|
|
227
|
+
externalImageChecking.value = true
|
|
228
|
+
try {
|
|
229
|
+
const url = await verifyExternalImage(normalizeExternalImageUrl(externalImageUrl.value))
|
|
230
|
+
if (isImageListView.value) {
|
|
231
|
+
appendCoverUrls([url])
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
const editor = imageDialogEditor.value ?? resolveEditor()
|
|
235
|
+
if (!editor) throw new Error('에디터를 준비하지 못했습니다. 다시 시도해 주세요.')
|
|
236
|
+
editor.chain().focus().setImage({ src: url }).run()
|
|
237
|
+
}
|
|
238
|
+
imageDialogOpen.value = false
|
|
239
|
+
}
|
|
240
|
+
catch (cause) {
|
|
241
|
+
imageUploadError.value = cause instanceof Error ? cause.message : '이미지 링크를 추가하지 못했습니다.'
|
|
242
|
+
}
|
|
243
|
+
finally {
|
|
244
|
+
externalImageChecking.value = false
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
150
248
|
function removeCoverAt(index: number) {
|
|
151
249
|
coverSlots.value = coverSlots.value.filter((_, i) => i !== index)
|
|
152
250
|
imageUploadError.value = ''
|
|
@@ -363,7 +461,7 @@ function insertYoutube(editor: any, value?: string | null): boolean {
|
|
|
363
461
|
return editor.chain().focus().setYoutubeVideo({ src }).run()
|
|
364
462
|
}
|
|
365
463
|
|
|
366
|
-
/** 공식 handlers API —
|
|
464
|
+
/** 공식 handlers API — 파일 업로드 또는 외부 HTTPS 이미지 링크 */
|
|
367
465
|
const handlers = computed(() => ({
|
|
368
466
|
image: {
|
|
369
467
|
canExecute: (editor: any) =>
|
|
@@ -372,7 +470,7 @@ const handlers = computed(() => ({
|
|
|
372
470
|
isDisabled: () => isImageListView.value,
|
|
373
471
|
execute: (editor: any) => {
|
|
374
472
|
if (isImageListView.value) return editor.chain()
|
|
375
|
-
|
|
473
|
+
openImageDialog(editor)
|
|
376
474
|
return editor.chain()
|
|
377
475
|
},
|
|
378
476
|
},
|
|
@@ -439,11 +537,72 @@ const toolbarItems = computed(() => {
|
|
|
439
537
|
]
|
|
440
538
|
})
|
|
441
539
|
|
|
540
|
+
const aiMenuItems = computed(() => [
|
|
541
|
+
[
|
|
542
|
+
{ label: '맞춤법 검사', icon: 'i-lucide-spell-check-2', onSelect: () => runWritingAssistant('proofread') },
|
|
543
|
+
{ label: '문장 다듬기', icon: 'i-lucide-wand-sparkles', onSelect: () => runWritingAssistant('polish') },
|
|
544
|
+
{ label: '자연스럽게 이어쓰기', icon: 'i-lucide-text-cursor-input', onSelect: () => runWritingAssistant('continue') },
|
|
545
|
+
{ label: '짧게 요약하기', icon: 'i-lucide-scan-text', onSelect: () => runWritingAssistant('summarize') },
|
|
546
|
+
],
|
|
547
|
+
[
|
|
548
|
+
{ label: '영어로 번역', icon: 'i-lucide-languages', onSelect: () => runWritingAssistant('translate-en') },
|
|
549
|
+
{ label: '한국어로 번역', icon: 'i-lucide-languages', onSelect: () => runWritingAssistant('translate-ko') },
|
|
550
|
+
],
|
|
551
|
+
[
|
|
552
|
+
{ label: '본문에서 제목 만들기', icon: 'i-lucide-heading-1', onSelect: () => runWritingAssistant('title') },
|
|
553
|
+
],
|
|
554
|
+
])
|
|
555
|
+
|
|
442
556
|
function resolveEditor(): any | null {
|
|
443
557
|
const ed = (editorRef.value as any)?.editor
|
|
444
558
|
return ed?.value ?? ed ?? null
|
|
445
559
|
}
|
|
446
560
|
|
|
561
|
+
async function runWritingAssistant(action: WritingAssistantAction) {
|
|
562
|
+
const editor = resolveEditor()
|
|
563
|
+
if (!editor || aiRunning.value) return
|
|
564
|
+
aiError.value = ''
|
|
565
|
+
|
|
566
|
+
const { from, to } = editor.state.selection
|
|
567
|
+
const hasSelection = from !== to
|
|
568
|
+
const selectedText = hasSelection ? editor.state.doc.textBetween(from, to, '\n') : ''
|
|
569
|
+
const source = action === 'title'
|
|
570
|
+
? plainTextFromHtml(content.value)
|
|
571
|
+
: hasSelection ? selectedText : content.value
|
|
572
|
+
|
|
573
|
+
aiRunning.value = true
|
|
574
|
+
try {
|
|
575
|
+
const result = await assist({
|
|
576
|
+
action,
|
|
577
|
+
content: source,
|
|
578
|
+
title: title.value,
|
|
579
|
+
selection: hasSelection || action === 'title',
|
|
580
|
+
})
|
|
581
|
+
|
|
582
|
+
if (action === 'title') {
|
|
583
|
+
title.value = result.replace(/^['"“”]|['"“”]$/g, '').trim()
|
|
584
|
+
}
|
|
585
|
+
else if (action === 'continue') {
|
|
586
|
+
const position = hasSelection ? to : editor.state.doc.content.size
|
|
587
|
+
editor.chain().focus().insertContentAt(position, result).run()
|
|
588
|
+
}
|
|
589
|
+
else if (hasSelection) {
|
|
590
|
+
editor.chain().focus().insertContentAt({ from, to }, result).run()
|
|
591
|
+
}
|
|
592
|
+
else {
|
|
593
|
+
editor.commands.setContent(result, { emitUpdate: true })
|
|
594
|
+
editor.chain().focus('end').run()
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
catch (cause) {
|
|
598
|
+
console.warn('[memi-board] writing assistant failed', cause)
|
|
599
|
+
aiError.value = cause instanceof Error ? cause.message : 'AI 작업에 실패했습니다. 다시 시도해 주세요.'
|
|
600
|
+
}
|
|
601
|
+
finally {
|
|
602
|
+
aiRunning.value = false
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
447
606
|
// 제목에서 Tab 누르면 툴바 버튼들 건너뛰고 본문으로 바로 이동
|
|
448
607
|
function focusContentFromTitle() {
|
|
449
608
|
resolveEditor()?.chain().focus().run()
|
|
@@ -746,17 +905,29 @@ async function handleSubmit() {
|
|
|
746
905
|
<p class="text-xs text-muted">
|
|
747
906
|
사진 {{ coverSlots.length }}/{{ COVER_IMAGE_MAX }} · 끌어 순서 변경 · 파일 드롭/붙여넣기로 추가
|
|
748
907
|
</p>
|
|
749
|
-
<
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
908
|
+
<div class="flex shrink-0 items-center gap-1">
|
|
909
|
+
<UButton
|
|
910
|
+
type="button"
|
|
911
|
+
size="xs"
|
|
912
|
+
color="neutral"
|
|
913
|
+
variant="ghost"
|
|
914
|
+
label="이미지 링크"
|
|
915
|
+
icon="i-lucide-link"
|
|
916
|
+
:disabled="imageUploading || coverSlots.length >= COVER_IMAGE_MAX"
|
|
917
|
+
@click="openImageDialog()"
|
|
918
|
+
/>
|
|
919
|
+
<UButton
|
|
920
|
+
v-if="coverSlots.length > 0"
|
|
921
|
+
type="button"
|
|
922
|
+
size="xs"
|
|
923
|
+
color="neutral"
|
|
924
|
+
variant="ghost"
|
|
925
|
+
label="사진 추가"
|
|
926
|
+
icon="i-lucide-plus"
|
|
927
|
+
:disabled="imageUploading || coverSlots.length >= COVER_IMAGE_MAX"
|
|
928
|
+
@click="pickCoverImages"
|
|
929
|
+
/>
|
|
930
|
+
</div>
|
|
760
931
|
</div>
|
|
761
932
|
|
|
762
933
|
<div
|
|
@@ -892,15 +1063,38 @@ async function handleSubmit() {
|
|
|
892
1063
|
class="board-content w-full"
|
|
893
1064
|
>
|
|
894
1065
|
<template #default="{ editor }">
|
|
895
|
-
<
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
1066
|
+
<div class="flex items-start border-b border-default">
|
|
1067
|
+
<UEditorToolbar
|
|
1068
|
+
:editor="editor"
|
|
1069
|
+
:items="toolbarItems"
|
|
1070
|
+
class="min-w-0 flex-1 p-2 flex-wrap"
|
|
1071
|
+
/>
|
|
1072
|
+
<UDropdownMenu :items="aiMenuItems">
|
|
1073
|
+
<UButton
|
|
1074
|
+
icon="i-lucide-sparkles"
|
|
1075
|
+
label="AI"
|
|
1076
|
+
color="neutral"
|
|
1077
|
+
variant="ghost"
|
|
1078
|
+
size="sm"
|
|
1079
|
+
class="m-2 shrink-0"
|
|
1080
|
+
:loading="aiRunning"
|
|
1081
|
+
:disabled="aiRunning"
|
|
1082
|
+
aria-label="AI 글쓰기 도우미"
|
|
1083
|
+
/>
|
|
1084
|
+
</UDropdownMenu>
|
|
1085
|
+
</div>
|
|
900
1086
|
<UEditorDragHandle :editor="editor" />
|
|
901
1087
|
</template>
|
|
902
1088
|
</UEditor>
|
|
903
1089
|
|
|
1090
|
+
<p
|
|
1091
|
+
v-if="aiError"
|
|
1092
|
+
class="flex items-center gap-2 border-t border-default px-4 py-2 text-xs text-error"
|
|
1093
|
+
>
|
|
1094
|
+
<UIcon name="i-lucide-circle-alert" class="size-3.5 shrink-0" />
|
|
1095
|
+
{{ aiError }}
|
|
1096
|
+
</p>
|
|
1097
|
+
|
|
904
1098
|
<p
|
|
905
1099
|
v-if="imageUploading && !isImageListView"
|
|
906
1100
|
class="flex items-center gap-2 border-t border-default px-4 py-2 text-xs text-muted"
|
|
@@ -963,6 +1157,91 @@ async function handleSubmit() {
|
|
|
963
1157
|
/>
|
|
964
1158
|
</div>
|
|
965
1159
|
</form>
|
|
1160
|
+
|
|
1161
|
+
<UModal
|
|
1162
|
+
v-model:open="imageDialogOpen"
|
|
1163
|
+
title="이미지 추가"
|
|
1164
|
+
:ui="{ content: 'sm:max-w-lg' }"
|
|
1165
|
+
>
|
|
1166
|
+
<template #body>
|
|
1167
|
+
<div class="flex flex-col gap-4">
|
|
1168
|
+
<UButton
|
|
1169
|
+
type="button"
|
|
1170
|
+
color="neutral"
|
|
1171
|
+
variant="outline"
|
|
1172
|
+
icon="i-lucide-upload"
|
|
1173
|
+
label="내 기기에서 이미지 선택"
|
|
1174
|
+
block
|
|
1175
|
+
:disabled="imageUploading"
|
|
1176
|
+
@click="chooseImageFileFromDialog"
|
|
1177
|
+
/>
|
|
1178
|
+
|
|
1179
|
+
<div class="flex items-center gap-3 text-xs text-muted">
|
|
1180
|
+
<span class="h-px flex-1 bg-default" />
|
|
1181
|
+
또는 이미지 링크
|
|
1182
|
+
<span class="h-px flex-1 bg-default" />
|
|
1183
|
+
</div>
|
|
1184
|
+
|
|
1185
|
+
<UFormField
|
|
1186
|
+
label="이미지 URL"
|
|
1187
|
+
help="HTTPS 주소만 사용할 수 있으며 원본 사이트에서 삭제되면 이미지도 표시되지 않습니다."
|
|
1188
|
+
>
|
|
1189
|
+
<UInput
|
|
1190
|
+
v-model="externalImageUrl"
|
|
1191
|
+
type="url"
|
|
1192
|
+
inputmode="url"
|
|
1193
|
+
placeholder="https://example.com/image.jpg"
|
|
1194
|
+
autocomplete="off"
|
|
1195
|
+
class="w-full"
|
|
1196
|
+
@keyup.enter="addExternalImage"
|
|
1197
|
+
/>
|
|
1198
|
+
</UFormField>
|
|
1199
|
+
|
|
1200
|
+
<div
|
|
1201
|
+
v-if="externalImageCandidate"
|
|
1202
|
+
class="flex min-h-32 items-center justify-center overflow-hidden rounded-lg border border-default bg-elevated/30"
|
|
1203
|
+
>
|
|
1204
|
+
<img
|
|
1205
|
+
:src="externalImageCandidate"
|
|
1206
|
+
alt="외부 이미지 미리보기"
|
|
1207
|
+
referrerpolicy="no-referrer"
|
|
1208
|
+
class="max-h-64 max-w-full object-contain"
|
|
1209
|
+
>
|
|
1210
|
+
</div>
|
|
1211
|
+
|
|
1212
|
+
<UAlert
|
|
1213
|
+
v-if="imageUploadError"
|
|
1214
|
+
color="error"
|
|
1215
|
+
variant="subtle"
|
|
1216
|
+
icon="i-lucide-circle-alert"
|
|
1217
|
+
:description="imageUploadError"
|
|
1218
|
+
/>
|
|
1219
|
+
</div>
|
|
1220
|
+
</template>
|
|
1221
|
+
|
|
1222
|
+
<template #footer>
|
|
1223
|
+
<div class="flex w-full justify-end gap-2">
|
|
1224
|
+
<UButton
|
|
1225
|
+
type="button"
|
|
1226
|
+
color="neutral"
|
|
1227
|
+
variant="ghost"
|
|
1228
|
+
:disabled="externalImageChecking"
|
|
1229
|
+
@click="imageDialogOpen = false"
|
|
1230
|
+
>
|
|
1231
|
+
취소
|
|
1232
|
+
</UButton>
|
|
1233
|
+
<UButton
|
|
1234
|
+
type="button"
|
|
1235
|
+
icon="i-lucide-link"
|
|
1236
|
+
:loading="externalImageChecking"
|
|
1237
|
+
:disabled="!externalImageCandidate"
|
|
1238
|
+
@click="addExternalImage"
|
|
1239
|
+
>
|
|
1240
|
+
링크 추가
|
|
1241
|
+
</UButton>
|
|
1242
|
+
</div>
|
|
1243
|
+
</template>
|
|
1244
|
+
</UModal>
|
|
966
1245
|
</template>
|
|
967
1246
|
|
|
968
1247
|
<style scoped>
|
|
@@ -69,6 +69,7 @@ const draft = ref<BoardCategory[]>([])
|
|
|
69
69
|
const newLabel = ref('')
|
|
70
70
|
const savingId = ref<string | null>(null)
|
|
71
71
|
const savedId = ref<string | null>(null)
|
|
72
|
+
const deletingId = ref<string | null>(null)
|
|
72
73
|
const ordering = ref(false)
|
|
73
74
|
const error = ref('')
|
|
74
75
|
const deleteConfirm = ref('')
|
|
@@ -132,14 +133,20 @@ async function moveCategory(index: number, offset: -1 | 1) {
|
|
|
132
133
|
|
|
133
134
|
async function removeCategory(index: number) {
|
|
134
135
|
const category = draft.value[index]
|
|
135
|
-
if (!category || !window.confirm(
|
|
136
|
+
if (!category || !window.confirm(
|
|
137
|
+
`‘${category.label}’ 게시판을 삭제하시겠습니까?\n\n이 게시판의 모든 게시물, 댓글, 대댓글, 본문 이미지와 첨부파일이 영구 삭제됩니다. 이 작업은 되돌릴 수 없습니다.`,
|
|
138
|
+
)) return
|
|
136
139
|
error.value = ''
|
|
140
|
+
deletingId.value = category.id
|
|
137
141
|
try {
|
|
138
142
|
await deleteCategory(category.id)
|
|
139
143
|
draft.value.splice(index, 1)
|
|
140
144
|
}
|
|
141
145
|
catch (cause) {
|
|
142
|
-
error.value = cause instanceof Error ? cause.message : '
|
|
146
|
+
error.value = cause instanceof Error ? cause.message : '게시판과 연결 데이터를 삭제하지 못했습니다.'
|
|
147
|
+
}
|
|
148
|
+
finally {
|
|
149
|
+
deletingId.value = null
|
|
143
150
|
}
|
|
144
151
|
}
|
|
145
152
|
|
|
@@ -299,7 +306,17 @@ async function runDeleteAll() {
|
|
|
299
306
|
size="sm"
|
|
300
307
|
:to="categoryTo(category.id)"
|
|
301
308
|
/>
|
|
302
|
-
<UButton
|
|
309
|
+
<UButton
|
|
310
|
+
v-if="!categoryId"
|
|
311
|
+
label="게시판 삭제"
|
|
312
|
+
icon="i-lucide-trash-2"
|
|
313
|
+
color="error"
|
|
314
|
+
variant="ghost"
|
|
315
|
+
size="sm"
|
|
316
|
+
:loading="deletingId === category.id"
|
|
317
|
+
:disabled="deletingId !== null"
|
|
318
|
+
@click="removeCategory(index)"
|
|
319
|
+
/>
|
|
303
320
|
<UButton
|
|
304
321
|
label="저장"
|
|
305
322
|
icon="i-lucide-save"
|
|
@@ -86,7 +86,7 @@ function F() {
|
|
|
86
86
|
onError: "allow",
|
|
87
87
|
moderateImages: !1,
|
|
88
88
|
localBlocklist: [],
|
|
89
|
-
blockBanThreshold:
|
|
89
|
+
blockBanThreshold: 10,
|
|
90
90
|
blockBanDecayMs: 864e5
|
|
91
91
|
},
|
|
92
92
|
seo: {
|
|
@@ -119,4 +119,4 @@ function B(e, t = 60) {
|
|
|
119
119
|
//#endregion
|
|
120
120
|
export { M as C, y as D, v as E, l as O, x as S, _ as T, C as _, L as a, T as b, f as c, j as d, N as f, w as g, O as h, R as i, d as k, h as l, A as m, z as n, p as o, k as p, I as r, m as s, B as t, g as u, D as v, b as w, S as x, E as y };
|
|
121
121
|
|
|
122
|
-
//# sourceMappingURL=slugify-
|
|
122
|
+
//# sourceMappingURL=slugify-B5XHr4SZ.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"slugify-CDMIDDCN.js","names":[],"sources":["../src/utils/boardPaths.ts","../src/config.ts","../src/utils/slugify.ts"],"sourcesContent":["/**\n * Firestore / Storage 경로\n *\n * ```\n * memiBoardUsers/{uid}\n * memiBoardSettings/{boardId} // 보드 설정 (구 memiBoards/{id} stub + settings/config 통합)\n * memiBoardPosts/{postId} // boardId 는 문서 필드\n * memiBoardPosts/{postId}/body/main\n * memiBoardComments/{commentId} // postId, boardId 는 문서 필드\n * memiBoardLikes/{postId}_{uid} // uid, postId, boardId 는 문서 필드\n * ```\n *\n * Storage: `memiBoardPosts/{postId}/...` (postId 가 전역 고유이므로 boardId 불필요)\n *\n * 카테고리 개념 없음 — 예전 category id 가 곧 boardId. boardId/postId 는 경로가\n * 아니라 문서 필드이므로, 보드/글 단위로 좁혀야 하는 쿼리는 항상 where 필터로\n * 스코핑한다 (컬렉션 자체는 항상 최상위 flat 컬렉션).\n */\nimport {\n collection,\n doc,\n type CollectionReference,\n type DocumentReference,\n type Firestore,\n} from 'firebase/firestore'\n\nexport const DEFAULT_POSTS_COLLECTION = 'memiBoardPosts'\nexport const DEFAULT_COMMENTS_COLLECTION = 'memiBoardComments'\nexport const DEFAULT_LIKES_COLLECTION = 'memiBoardLikes'\nexport const DEFAULT_SETTINGS_COLLECTION = 'memiBoardSettings'\nexport const DEFAULT_USERS_COLLECTION = 'memiBoardUsers'\n\nexport interface BoardPathConfig {\n postsCollection: string\n commentsCollection: string\n likesCollection: string\n settingsCollection: string\n usersCollection: string\n}\n\nexport function resolveBoardPathConfig(input: {\n postsCollection?: string\n commentsCollection?: string\n likesCollection?: string\n settingsCollection?: string\n usersCollection?: string\n}): BoardPathConfig {\n return {\n postsCollection: input.postsCollection?.trim() || DEFAULT_POSTS_COLLECTION,\n commentsCollection: input.commentsCollection?.trim() || DEFAULT_COMMENTS_COLLECTION,\n likesCollection: input.likesCollection?.trim() || DEFAULT_LIKES_COLLECTION,\n settingsCollection: input.settingsCollection?.trim() || DEFAULT_SETTINGS_COLLECTION,\n usersCollection: input.usersCollection?.trim() || DEFAULT_USERS_COLLECTION,\n }\n}\n\n/** 보드 설정 목록/문서 — 구 memiBoards/{boardId} stub + settings/config 통합 */\nexport function settingsCol(db: Firestore, cfg: BoardPathConfig): CollectionReference {\n return collection(db, cfg.settingsCollection)\n}\n\nexport function settingsDoc(\n db: Firestore,\n cfg: BoardPathConfig,\n boardId: string,\n): DocumentReference {\n return doc(db, cfg.settingsCollection, boardId)\n}\n\nexport function postsCol(db: Firestore, cfg: BoardPathConfig): CollectionReference {\n return collection(db, cfg.postsCollection)\n}\n\nexport function postDoc(db: Firestore, cfg: BoardPathConfig, postId: string): DocumentReference {\n return doc(db, cfg.postsCollection, postId)\n}\n\nexport function postBodyDoc(\n db: Firestore,\n cfg: BoardPathConfig,\n postId: string,\n): DocumentReference {\n return doc(db, cfg.postsCollection, postId, 'body', 'main')\n}\n\nexport function commentsCol(db: Firestore, cfg: BoardPathConfig): CollectionReference {\n return collection(db, cfg.commentsCollection)\n}\n\nexport function commentDoc(\n db: Firestore,\n cfg: BoardPathConfig,\n commentId: string,\n): DocumentReference {\n return doc(db, cfg.commentsCollection, commentId)\n}\n\nexport function likesCol(db: Firestore, cfg: BoardPathConfig): CollectionReference {\n return collection(db, cfg.likesCollection)\n}\n\n/** 좋아요 문서ID 관례: `${postId}_${uid}` — 유저당 글당 좋아요 1개, 토글은 create-or-delete */\nexport function likeDocId(postId: string, uid: string): string {\n return `${postId}_${uid}`\n}\n\nexport function likeDoc(\n db: Firestore,\n cfg: BoardPathConfig,\n postId: string,\n uid: string,\n): DocumentReference {\n return doc(db, cfg.likesCollection, likeDocId(postId, uid))\n}\n\nexport function boardUsersCol(db: Firestore, cfg: BoardPathConfig): CollectionReference {\n return collection(db, cfg.usersCollection)\n}\n\nexport function boardUserDoc(\n db: Firestore,\n cfg: BoardPathConfig,\n uid: string,\n): DocumentReference {\n return doc(db, cfg.usersCollection, uid)\n}\n\n/** 이메일만 담는 비공개 문서 — memiBoardUsers/{uid}는 공개 프로필이라 이메일만 분리한다. */\nexport function boardUserPrivateDoc(\n db: Firestore,\n cfg: BoardPathConfig,\n uid: string,\n): DocumentReference {\n return doc(db, cfg.usersCollection, uid, 'private', 'info')\n}\n\n/** 본인만 보는 작성자 메모 — 문서 ID 자체가 targetUid라 대상당 메모가 하나로 고정된다. */\nexport function authorMemoDoc(\n db: Firestore,\n cfg: BoardPathConfig,\n viewerUid: string,\n targetUid: string,\n): DocumentReference {\n return doc(db, cfg.usersCollection, viewerUid, 'memos', targetUid)\n}\n\n/** postId 가 전역 고유이므로 boardId 프리픽스 불필요 */\nexport function postStorageFolder(cfg: BoardPathConfig, postId: string): string {\n return `${cfg.postsCollection}/${postId}`\n}\n\nexport function boardSsrKey(cfg: BoardPathConfig, suffix: string): string {\n return `${cfg.postsCollection}/${cfg.usersCollection}/${suffix}`\n}\n","import { reactive } from 'vue'\nimport {\n DEFAULT_COMMENTS_COLLECTION,\n DEFAULT_LIKES_COLLECTION,\n DEFAULT_POSTS_COLLECTION,\n DEFAULT_SETTINGS_COLLECTION,\n DEFAULT_USERS_COLLECTION,\n resolveBoardPathConfig,\n type BoardPathConfig,\n} from './utils/boardPaths'\n\nexport interface MemiBoardAuthOptions {\n providers?: Array<'google' | 'apple' | 'emailPassword'>\n}\n\nexport interface MemiBoardModerationOptions {\n enabled?: boolean\n model?: string\n localBlocklist?: string[]\n onError?: 'allow' | 'block'\n moderateImages?: boolean\n useLimitedUseAppCheckTokens?: boolean\n blockBanThreshold?: number\n blockBanDecayMs?: number\n}\n\nexport interface MemiBoardSeoOptions {\n enabled?: boolean\n siteName?: string\n siteUrl?: string\n defaultOgImage?: string\n /**\n * 호스트 게시판 base path (trailing slash 없음). 기본 '/board'\n * canonical: {base}, {base}/{boardId}, {base}/{boardId}/{slug}\n */\n basePath?: string\n}\n\nexport interface MemiBoardConfig {\n /** 글 컬렉션. 기본 `memiBoardPosts` */\n postsCollection: string\n /** 댓글 컬렉션. 기본 `memiBoardComments` */\n commentsCollection: string\n /** 좋아요 컬렉션. 기본 `memiBoardLikes` */\n likesCollection: string\n /** 보드 설정 컬렉션. 기본 `memiBoardSettings` */\n settingsCollection: string\n /** 역할 문서 컬렉션. 기본 `memiBoardUsers` */\n usersCollection: string\n auth: MemiBoardAuthOptions\n moderation: MemiBoardModerationOptions\n seo: MemiBoardSeoOptions\n}\n\nconst GLOBAL_KEY = '__MEMI_BOARD_CONFIG__' as const\n\nfunction getSharedConfig(): MemiBoardConfig {\n const g = globalThis as typeof globalThis & {\n [GLOBAL_KEY]?: MemiBoardConfig\n }\n if (!g[GLOBAL_KEY]) {\n g[GLOBAL_KEY] = reactive<MemiBoardConfig>({\n postsCollection: DEFAULT_POSTS_COLLECTION,\n commentsCollection: DEFAULT_COMMENTS_COLLECTION,\n likesCollection: DEFAULT_LIKES_COLLECTION,\n settingsCollection: DEFAULT_SETTINGS_COLLECTION,\n usersCollection: DEFAULT_USERS_COLLECTION,\n auth: { providers: ['google', 'apple'] },\n moderation: {\n enabled: true,\n model: 'gemini-3.5-flash-lite',\n onError: 'allow',\n moderateImages: false,\n localBlocklist: [],\n blockBanThreshold: 3,\n blockBanDecayMs: 24 * 60 * 60 * 1000,\n },\n seo: {\n enabled: true,\n siteName: 'Board',\n siteUrl: '',\n defaultOgImage: '',\n basePath: '/board',\n },\n })\n }\n return g[GLOBAL_KEY]\n}\n\nexport function configureMemiBoard(options: Partial<MemiBoardConfig>): void {\n const config = getSharedConfig()\n if (options.postsCollection != null && options.postsCollection !== '') {\n config.postsCollection = options.postsCollection.trim()\n }\n if (options.commentsCollection != null && options.commentsCollection !== '') {\n config.commentsCollection = options.commentsCollection.trim()\n }\n if (options.likesCollection != null && options.likesCollection !== '') {\n config.likesCollection = options.likesCollection.trim()\n }\n if (options.settingsCollection != null && options.settingsCollection !== '') {\n config.settingsCollection = options.settingsCollection.trim()\n }\n if (options.usersCollection != null && options.usersCollection !== '') {\n config.usersCollection = options.usersCollection.trim()\n }\n if (options.auth) Object.assign(config.auth, options.auth)\n if (options.moderation) Object.assign(config.moderation, options.moderation)\n if (options.seo) Object.assign(config.seo, options.seo)\n}\n\nexport function useMemiBoardConfig(): MemiBoardConfig {\n return getSharedConfig()\n}\n\nexport function useBoardPathConfig(): BoardPathConfig {\n return resolveBoardPathConfig(useMemiBoardConfig())\n}\n","/**\n * 제목 문자열을 URL-friendly slug로 변환\n * e.g. \"게시판 모듈 만들기!!\" → \"게시판-모듈-만들기\"\n */\nexport function slugify(text: string): string {\n return text\n .trim()\n .replace(/\\s+/g, '-') // 공백(연속 포함) → 하이픈\n .replace(/[^a-zA-Z0-9가-힣ㄱ-ㅎㅏ-ㅣ-]/g, '') // 허용 문자 외 제거\n .replace(/-+/g, '-') // 연속 하이픈 → 단일 하이픈\n .replace(/^-+|-+$/g, '') // 앞뒤 하이픈 제거\n}\n\n/** Storage 경로/파일명에 안전한 문자열로 변환 (한글은 유지) */\nexport function safeFileName(name: string, maxLength = 60): string {\n return name.replace(/[^a-zA-Z0-9가-힣ㄱ-ㅎ._-]/g, '-').slice(0, maxLength)\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"slugify-B5XHr4SZ.js","names":[],"sources":["../src/utils/boardPaths.ts","../src/config.ts","../src/utils/slugify.ts"],"sourcesContent":["/**\n * Firestore / Storage 경로\n *\n * ```\n * memiBoardUsers/{uid}\n * memiBoardSettings/{boardId} // 보드 설정 (구 memiBoards/{id} stub + settings/config 통합)\n * memiBoardPosts/{postId} // boardId 는 문서 필드\n * memiBoardPosts/{postId}/body/main\n * memiBoardComments/{commentId} // postId, boardId 는 문서 필드\n * memiBoardLikes/{postId}_{uid} // uid, postId, boardId 는 문서 필드\n * ```\n *\n * Storage: `memiBoardPosts/{postId}/...` (postId 가 전역 고유이므로 boardId 불필요)\n *\n * 카테고리 개념 없음 — 예전 category id 가 곧 boardId. boardId/postId 는 경로가\n * 아니라 문서 필드이므로, 보드/글 단위로 좁혀야 하는 쿼리는 항상 where 필터로\n * 스코핑한다 (컬렉션 자체는 항상 최상위 flat 컬렉션).\n */\nimport {\n collection,\n doc,\n type CollectionReference,\n type DocumentReference,\n type Firestore,\n} from 'firebase/firestore'\n\nexport const DEFAULT_POSTS_COLLECTION = 'memiBoardPosts'\nexport const DEFAULT_COMMENTS_COLLECTION = 'memiBoardComments'\nexport const DEFAULT_LIKES_COLLECTION = 'memiBoardLikes'\nexport const DEFAULT_SETTINGS_COLLECTION = 'memiBoardSettings'\nexport const DEFAULT_USERS_COLLECTION = 'memiBoardUsers'\n\nexport interface BoardPathConfig {\n postsCollection: string\n commentsCollection: string\n likesCollection: string\n settingsCollection: string\n usersCollection: string\n}\n\nexport function resolveBoardPathConfig(input: {\n postsCollection?: string\n commentsCollection?: string\n likesCollection?: string\n settingsCollection?: string\n usersCollection?: string\n}): BoardPathConfig {\n return {\n postsCollection: input.postsCollection?.trim() || DEFAULT_POSTS_COLLECTION,\n commentsCollection: input.commentsCollection?.trim() || DEFAULT_COMMENTS_COLLECTION,\n likesCollection: input.likesCollection?.trim() || DEFAULT_LIKES_COLLECTION,\n settingsCollection: input.settingsCollection?.trim() || DEFAULT_SETTINGS_COLLECTION,\n usersCollection: input.usersCollection?.trim() || DEFAULT_USERS_COLLECTION,\n }\n}\n\n/** 보드 설정 목록/문서 — 구 memiBoards/{boardId} stub + settings/config 통합 */\nexport function settingsCol(db: Firestore, cfg: BoardPathConfig): CollectionReference {\n return collection(db, cfg.settingsCollection)\n}\n\nexport function settingsDoc(\n db: Firestore,\n cfg: BoardPathConfig,\n boardId: string,\n): DocumentReference {\n return doc(db, cfg.settingsCollection, boardId)\n}\n\nexport function postsCol(db: Firestore, cfg: BoardPathConfig): CollectionReference {\n return collection(db, cfg.postsCollection)\n}\n\nexport function postDoc(db: Firestore, cfg: BoardPathConfig, postId: string): DocumentReference {\n return doc(db, cfg.postsCollection, postId)\n}\n\nexport function postBodyDoc(\n db: Firestore,\n cfg: BoardPathConfig,\n postId: string,\n): DocumentReference {\n return doc(db, cfg.postsCollection, postId, 'body', 'main')\n}\n\nexport function commentsCol(db: Firestore, cfg: BoardPathConfig): CollectionReference {\n return collection(db, cfg.commentsCollection)\n}\n\nexport function commentDoc(\n db: Firestore,\n cfg: BoardPathConfig,\n commentId: string,\n): DocumentReference {\n return doc(db, cfg.commentsCollection, commentId)\n}\n\nexport function likesCol(db: Firestore, cfg: BoardPathConfig): CollectionReference {\n return collection(db, cfg.likesCollection)\n}\n\n/** 좋아요 문서ID 관례: `${postId}_${uid}` — 유저당 글당 좋아요 1개, 토글은 create-or-delete */\nexport function likeDocId(postId: string, uid: string): string {\n return `${postId}_${uid}`\n}\n\nexport function likeDoc(\n db: Firestore,\n cfg: BoardPathConfig,\n postId: string,\n uid: string,\n): DocumentReference {\n return doc(db, cfg.likesCollection, likeDocId(postId, uid))\n}\n\nexport function boardUsersCol(db: Firestore, cfg: BoardPathConfig): CollectionReference {\n return collection(db, cfg.usersCollection)\n}\n\nexport function boardUserDoc(\n db: Firestore,\n cfg: BoardPathConfig,\n uid: string,\n): DocumentReference {\n return doc(db, cfg.usersCollection, uid)\n}\n\n/** 이메일만 담는 비공개 문서 — memiBoardUsers/{uid}는 공개 프로필이라 이메일만 분리한다. */\nexport function boardUserPrivateDoc(\n db: Firestore,\n cfg: BoardPathConfig,\n uid: string,\n): DocumentReference {\n return doc(db, cfg.usersCollection, uid, 'private', 'info')\n}\n\n/** 본인만 보는 작성자 메모 — 문서 ID 자체가 targetUid라 대상당 메모가 하나로 고정된다. */\nexport function authorMemoDoc(\n db: Firestore,\n cfg: BoardPathConfig,\n viewerUid: string,\n targetUid: string,\n): DocumentReference {\n return doc(db, cfg.usersCollection, viewerUid, 'memos', targetUid)\n}\n\n/** postId 가 전역 고유이므로 boardId 프리픽스 불필요 */\nexport function postStorageFolder(cfg: BoardPathConfig, postId: string): string {\n return `${cfg.postsCollection}/${postId}`\n}\n\nexport function boardSsrKey(cfg: BoardPathConfig, suffix: string): string {\n return `${cfg.postsCollection}/${cfg.usersCollection}/${suffix}`\n}\n","import { reactive } from 'vue'\nimport {\n DEFAULT_COMMENTS_COLLECTION,\n DEFAULT_LIKES_COLLECTION,\n DEFAULT_POSTS_COLLECTION,\n DEFAULT_SETTINGS_COLLECTION,\n DEFAULT_USERS_COLLECTION,\n resolveBoardPathConfig,\n type BoardPathConfig,\n} from './utils/boardPaths'\n\nexport interface MemiBoardAuthOptions {\n providers?: Array<'google' | 'apple' | 'emailPassword'>\n}\n\nexport interface MemiBoardModerationOptions {\n enabled?: boolean\n model?: string\n localBlocklist?: string[]\n onError?: 'allow' | 'block'\n moderateImages?: boolean\n useLimitedUseAppCheckTokens?: boolean\n blockBanThreshold?: number\n blockBanDecayMs?: number\n}\n\nexport interface MemiBoardSeoOptions {\n enabled?: boolean\n siteName?: string\n siteUrl?: string\n defaultOgImage?: string\n /**\n * 호스트 게시판 base path (trailing slash 없음). 기본 '/board'\n * canonical: {base}, {base}/{boardId}, {base}/{boardId}/{slug}\n */\n basePath?: string\n}\n\nexport interface MemiBoardConfig {\n /** 글 컬렉션. 기본 `memiBoardPosts` */\n postsCollection: string\n /** 댓글 컬렉션. 기본 `memiBoardComments` */\n commentsCollection: string\n /** 좋아요 컬렉션. 기본 `memiBoardLikes` */\n likesCollection: string\n /** 보드 설정 컬렉션. 기본 `memiBoardSettings` */\n settingsCollection: string\n /** 역할 문서 컬렉션. 기본 `memiBoardUsers` */\n usersCollection: string\n auth: MemiBoardAuthOptions\n moderation: MemiBoardModerationOptions\n seo: MemiBoardSeoOptions\n}\n\nconst GLOBAL_KEY = '__MEMI_BOARD_CONFIG__' as const\n\nfunction getSharedConfig(): MemiBoardConfig {\n const g = globalThis as typeof globalThis & {\n [GLOBAL_KEY]?: MemiBoardConfig\n }\n if (!g[GLOBAL_KEY]) {\n g[GLOBAL_KEY] = reactive<MemiBoardConfig>({\n postsCollection: DEFAULT_POSTS_COLLECTION,\n commentsCollection: DEFAULT_COMMENTS_COLLECTION,\n likesCollection: DEFAULT_LIKES_COLLECTION,\n settingsCollection: DEFAULT_SETTINGS_COLLECTION,\n usersCollection: DEFAULT_USERS_COLLECTION,\n auth: { providers: ['google', 'apple'] },\n moderation: {\n enabled: true,\n model: 'gemini-3.5-flash-lite',\n onError: 'allow',\n moderateImages: false,\n localBlocklist: [],\n blockBanThreshold: 10,\n blockBanDecayMs: 24 * 60 * 60 * 1000,\n },\n seo: {\n enabled: true,\n siteName: 'Board',\n siteUrl: '',\n defaultOgImage: '',\n basePath: '/board',\n },\n })\n }\n return g[GLOBAL_KEY]\n}\n\nexport function configureMemiBoard(options: Partial<MemiBoardConfig>): void {\n const config = getSharedConfig()\n if (options.postsCollection != null && options.postsCollection !== '') {\n config.postsCollection = options.postsCollection.trim()\n }\n if (options.commentsCollection != null && options.commentsCollection !== '') {\n config.commentsCollection = options.commentsCollection.trim()\n }\n if (options.likesCollection != null && options.likesCollection !== '') {\n config.likesCollection = options.likesCollection.trim()\n }\n if (options.settingsCollection != null && options.settingsCollection !== '') {\n config.settingsCollection = options.settingsCollection.trim()\n }\n if (options.usersCollection != null && options.usersCollection !== '') {\n config.usersCollection = options.usersCollection.trim()\n }\n if (options.auth) Object.assign(config.auth, options.auth)\n if (options.moderation) Object.assign(config.moderation, options.moderation)\n if (options.seo) Object.assign(config.seo, options.seo)\n}\n\nexport function useMemiBoardConfig(): MemiBoardConfig {\n return getSharedConfig()\n}\n\nexport function useBoardPathConfig(): BoardPathConfig {\n return resolveBoardPathConfig(useMemiBoardConfig())\n}\n","/**\n * 제목 문자열을 URL-friendly slug로 변환\n * e.g. \"게시판 모듈 만들기!!\" → \"게시판-모듈-만들기\"\n */\nexport function slugify(text: string): string {\n return text\n .trim()\n .replace(/\\s+/g, '-') // 공백(연속 포함) → 하이픈\n .replace(/[^a-zA-Z0-9가-힣ㄱ-ㅎㅏ-ㅣ-]/g, '') // 허용 문자 외 제거\n .replace(/-+/g, '-') // 연속 하이픈 → 단일 하이픈\n .replace(/^-+|-+$/g, '') // 앞뒤 하이픈 제거\n}\n\n/** Storage 경로/파일명에 안전한 문자열로 변환 (한글은 유지) */\nexport function safeFileName(name: string, maxLength = 60): string {\n return name.replace(/[^a-zA-Z0-9가-힣ㄱ-ㅎ._-]/g, '-').slice(0, maxLength)\n}\n"],"mappings":"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"}
|
package/dist/storage.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { C as e, i as t, k as n, t as r } from "./slugify-
|
|
1
|
+
import { C as e, i as t, k as n, t as r } from "./slugify-B5XHr4SZ.js";
|
|
2
2
|
import { useFirebaseApp as i } from "vuefire";
|
|
3
3
|
import { deleteObject as a, getDownloadURL as o, getStorage as s, ref as c, uploadBytes as l, uploadBytesResumable as u } from "firebase/storage";
|
|
4
4
|
//#region src/utils/compressImage.ts
|
|
@@ -28,7 +28,7 @@ function m(e) {
|
|
|
28
28
|
return t === "image/heic" || t === "image/heif" || n.endsWith(".heic") || n.endsWith(".heif");
|
|
29
29
|
}
|
|
30
30
|
async function h(e, t = .85) {
|
|
31
|
-
let { default: r } = await import("./heic2any-
|
|
31
|
+
let { default: r } = await import("./heic2any-BWRKffWN.js").then((e) => /* @__PURE__ */ n(e.default, 1)), i = await r({
|
|
32
32
|
blob: e,
|
|
33
33
|
toType: "image/jpeg",
|
|
34
34
|
quality: t
|
package/dist/types.d.ts
CHANGED
|
@@ -114,6 +114,10 @@ export interface BoardUserModel {
|
|
|
114
114
|
displayName?: string | null;
|
|
115
115
|
photoURL?: string | null;
|
|
116
116
|
moderationBlockCount?: number;
|
|
117
|
+
/** AI 글쓰기 도우미 24시간 사용 횟수. */
|
|
118
|
+
aiWritingCount?: number;
|
|
119
|
+
/** AI 글쓰기 도우미 현재 24시간 구간 시작 시각. */
|
|
120
|
+
aiWritingWindowAt?: Timestamp;
|
|
117
121
|
updatedAt?: Timestamp;
|
|
118
122
|
/** 최초 가입일. 기존 사용자는 비어 있고, 백필 스크립트나 다음 로그인 시 채운다. */
|
|
119
123
|
joinedAt?: Timestamp;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { Firestore } from 'firebase/firestore';
|
|
2
|
+
import { FirebaseStorage } from 'firebase/storage';
|
|
3
|
+
import { BoardPathConfig } from './boardPaths';
|
|
4
|
+
/** 글 하나의 댓글(답글 포함), 본문, 첨부 Storage, 메타를 순서대로 삭제한다. */
|
|
5
|
+
export declare function deletePostCascade(db: Firestore, storage: FirebaseStorage, cfg: BoardPathConfig, id: string): Promise<void>;
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 게시판 검열 차단 누적 → 일시 이용 제한 (호스트 contentWarning 과 동일 모델).
|
|
3
3
|
* - 콘텐츠 차단(로컬/AI flagged) 시 +1
|
|
4
|
-
* - 유효 경고 ≥ threshold(기본
|
|
4
|
+
* - 유효 경고 ≥ threshold(기본 10) → 글/댓글 작성 불가
|
|
5
5
|
* - 24h마다 lazy 차감 (moderationBlockAt 기준)
|
|
6
6
|
*/
|
|
7
|
-
export declare const DEFAULT_BLOCK_BAN_THRESHOLD =
|
|
7
|
+
export declare const DEFAULT_BLOCK_BAN_THRESHOLD = 10;
|
|
8
8
|
export declare const DEFAULT_BLOCK_BAN_DECAY_MS: number;
|
|
9
9
|
export declare function effectiveModerationBlockCount(count: number | null | undefined, blockAtMs: number | null | undefined, nowMs?: number, decayMs?: number): number;
|
|
10
10
|
/** 제한 해제 시각(ms). 제한 없으면 null. */
|
|
@@ -138,13 +138,33 @@ service cloud.firestore {
|
|
|
138
138
|
&& request.resource.data.role == resource.data.role
|
|
139
139
|
&& (
|
|
140
140
|
!request.resource.data.diff(resource.data).affectedKeys()
|
|
141
|
-
.hasAny(['moderationBlockCount', 'moderationBlockAt'])
|
|
141
|
+
.hasAny(['moderationBlockCount', 'moderationBlockAt', 'aiWritingCount', 'aiWritingWindowAt'])
|
|
142
142
|
|| (
|
|
143
143
|
request.resource.data.diff(resource.data).affectedKeys()
|
|
144
144
|
.hasOnly(['moderationBlockCount', 'moderationBlockAt', 'updatedAt'])
|
|
145
145
|
&& request.resource.data.moderationBlockCount
|
|
146
146
|
== resource.data.get('moderationBlockCount', 0) + 1
|
|
147
147
|
)
|
|
148
|
+
|| (
|
|
149
|
+
request.resource.data.diff(resource.data).affectedKeys()
|
|
150
|
+
.hasOnly(['aiWritingCount', 'aiWritingWindowAt', 'updatedAt'])
|
|
151
|
+
&& request.resource.data.aiWritingCount <= 10
|
|
152
|
+
&& (
|
|
153
|
+
(
|
|
154
|
+
'aiWritingWindowAt' in resource.data
|
|
155
|
+
&& request.time < resource.data.aiWritingWindowAt + duration.value(24, 'h')
|
|
156
|
+
&& request.resource.data.aiWritingWindowAt == resource.data.aiWritingWindowAt
|
|
157
|
+
&& request.resource.data.aiWritingCount
|
|
158
|
+
== resource.data.get('aiWritingCount', 0) + 1
|
|
159
|
+
)
|
|
160
|
+
|| (
|
|
161
|
+
(!('aiWritingWindowAt' in resource.data)
|
|
162
|
+
|| request.time >= resource.data.aiWritingWindowAt + duration.value(24, 'h'))
|
|
163
|
+
&& request.resource.data.aiWritingWindowAt == request.time
|
|
164
|
+
&& request.resource.data.aiWritingCount == 1
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
)
|
|
148
168
|
));
|
|
149
169
|
|
|
150
170
|
// 다른 작성자에 대해 나만 보는 개인 메모. 문서 ID가 targetUid라 대상당 하나로
|