memi-board 0.46.0 → 0.46.2
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 +1 -0
- package/dist/{ar-BCQdRw_S.js → ar-D_6hknjh.js} +22 -1
- package/dist/ar-D_6hknjh.js.map +1 -0
- package/dist/composables/fetchPublicSeo.d.ts +1 -1
- package/dist/composables/useMemiBoardAutoTranslate.d.ts +6 -6
- package/dist/composables/useMemiBoardPosts.d.ts +6 -1
- package/dist/composables/useMemiBoardSettings.d.ts +5 -4
- package/dist/composables/useMemiBoardWritingAssistant.d.ts +8 -0
- package/dist/{de-ddWQNaqs.js → de-DQqSsBsZ.js} +22 -1
- package/dist/de-DQqSsBsZ.js.map +1 -0
- package/dist/{en-CaPfPNDy.js → en-Broh-9df.js} +19 -1
- package/dist/en-Broh-9df.js.map +1 -0
- package/dist/{es-7JHjGVlM.js → es-D3eEYN6w.js} +22 -1
- package/dist/es-D3eEYN6w.js.map +1 -0
- package/dist/{fr-C3NCMNoy.js → fr-CbXPRKeU.js} +22 -1
- package/dist/fr-CbXPRKeU.js.map +1 -0
- package/dist/{id-C8hCAMPJ.js → id-DxVqvPpo.js} +22 -1
- package/dist/id-DxVqvPpo.js.map +1 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1266 -921
- package/dist/index.js.map +1 -1
- package/dist/{ja-oTWri4hY.js → ja-D2dIWUse.js} +22 -1
- package/dist/{ja-oTWri4hY.js.map → ja-D2dIWUse.js.map} +1 -1
- package/dist/{pt-B9EH4scA.js → pt-BAuHdglU.js} +22 -1
- package/dist/pt-BAuHdglU.js.map +1 -0
- package/dist/runtime/components/CategorySettings.vue +80 -14
- package/dist/runtime/components/Detail.vue +9 -7
- package/dist/runtime/components/Editor.vue +79 -8
- package/dist/runtime/components/List.vue +2 -2
- package/dist/runtime/components/ListDefault.vue +16 -6
- package/dist/runtime/components/ListDense.vue +8 -4
- package/dist/runtime/components/ListImage.vue +13 -6
- package/dist/runtime/components/ListVideo.vue +14 -5
- package/dist/runtime/components/Section.vue +10 -3
- package/dist/runtime/components/Settings.vue +77 -11
- package/dist/runtime/composables/useMemiBoardSeo.ts +3 -3
- package/dist/{slugify-DJ82ifCd.js → slugify-B05Qykf_.js} +28 -10
- package/dist/slugify-B05Qykf_.js.map +1 -0
- package/dist/storage.js +1 -1
- package/dist/types.d.ts +48 -0
- package/dist/utils/localizedFields.d.ts +33 -0
- package/dist/{zh-D5rDYV6E.js → zh-CMyzTdQ5.js} +22 -1
- package/dist/zh-CMyzTdQ5.js.map +1 -0
- package/package.json +1 -1
- package/dist/ar-BCQdRw_S.js.map +0 -1
- package/dist/de-ddWQNaqs.js.map +0 -1
- package/dist/en-CaPfPNDy.js.map +0 -1
- package/dist/es-7JHjGVlM.js.map +0 -1
- package/dist/fr-C3NCMNoy.js.map +0 -1
- package/dist/id-C8hCAMPJ.js.map +0 -1
- package/dist/pt-B9EH4scA.js.map +0 -1
- package/dist/slugify-DJ82ifCd.js.map +0 -1
- package/dist/zh-D5rDYV6E.js.map +0 -1
|
@@ -11,7 +11,7 @@ import { imeSafeSubmitClick, imeSafeSubmitPointerDown } from 'memi-board/runtime
|
|
|
11
11
|
import Youtube from '@tiptap/extension-youtube'
|
|
12
12
|
import type { DropdownMenuItem, EditorSuggestionMenuItem } from '@nuxt/ui'
|
|
13
13
|
import { mapEditorItems } from '@nuxt/ui/utils/editor'
|
|
14
|
-
import type { Attachment, EditorImageEntry } from 'memi-board/runtime'
|
|
14
|
+
import type { Attachment, EditorImageEntry, PostTranslationSet } from 'memi-board/runtime'
|
|
15
15
|
import type { WritingAssistantAction } from 'memi-board/runtime'
|
|
16
16
|
import {
|
|
17
17
|
useMemiBoardAuth,
|
|
@@ -22,6 +22,9 @@ import {
|
|
|
22
22
|
hasBodyImage,
|
|
23
23
|
hasBodyText,
|
|
24
24
|
plainTextFromHtml,
|
|
25
|
+
titleFromBody,
|
|
26
|
+
buildPostPreview,
|
|
27
|
+
hashText,
|
|
25
28
|
useMemiBoardWritingAssistant,
|
|
26
29
|
} from 'memi-board/runtime'
|
|
27
30
|
import { useMemiBoardStorage, EDITOR_IMAGE_SOURCE_MAX_BYTES } from 'memi-board/storage'
|
|
@@ -39,14 +42,14 @@ const emit = defineEmits<{ saved: [id: string], cancel: [] }>()
|
|
|
39
42
|
|
|
40
43
|
const { user, isSignedIn, isAdmin, isWriteRestricted, restrictedMessage } = useMemiBoardAuth()
|
|
41
44
|
const resolvedBoardId = computed(() => props.boardId || props.fixedCategory || '')
|
|
42
|
-
const { createPostId, getPost, createPost, updatePost, resolveUniqueSlug } = useMemiBoardPosts(resolvedBoardId)
|
|
45
|
+
const { createPostId, getPost, createPost, updatePost, savePostTranslations, resolveUniqueSlug } = useMemiBoardPosts(resolvedBoardId)
|
|
43
46
|
const { getBoard, ensureSettings } = useMemiBoardSettings()
|
|
44
47
|
// 보드 설정 언어 → 호스트 언어 순.
|
|
45
48
|
const { t, dir, locale } = useMemiBoardI18n({ boardLocale: () => getBoard(resolvedBoardId.value)?.locale })
|
|
46
49
|
// provide 한 컴포넌트 자신은 inject 를 못 받으므로 AI 프롬프트 언어를 직접 넘긴다.
|
|
47
50
|
const { checkText } = useMemiBoardModeration({ locale })
|
|
48
51
|
const { uploadEditorImage } = useMemiBoardStorage()
|
|
49
|
-
const { assist } = useMemiBoardWritingAssistant({ locale })
|
|
52
|
+
const { assist, translatePost } = useMemiBoardWritingAssistant({ locale })
|
|
50
53
|
|
|
51
54
|
const title = ref('')
|
|
52
55
|
/** UEditor content-type=html (shineb 와 동일) */
|
|
@@ -78,6 +81,9 @@ const externalImageCandidate = computed(() => {
|
|
|
78
81
|
})
|
|
79
82
|
const aiRunning = ref(false)
|
|
80
83
|
const aiError = ref('')
|
|
84
|
+
const aiNotice = ref('')
|
|
85
|
+
const pendingTranslations = ref<PostTranslationSet | null>(null)
|
|
86
|
+
const pendingTranslationKey = ref('')
|
|
81
87
|
const aiCustomDialogOpen = ref(false)
|
|
82
88
|
const aiCustomInstruction = ref('')
|
|
83
89
|
|
|
@@ -652,8 +658,7 @@ const aiMenuItems = computed(() => [
|
|
|
652
658
|
{ label: t('editor.ai.summarize'), icon: 'i-lucide-scan-text', onSelect: () => runWritingAssistant('summarize') },
|
|
653
659
|
],
|
|
654
660
|
[
|
|
655
|
-
{ label: t('editor.ai.
|
|
656
|
-
{ label: t('editor.ai.translateKo'), icon: 'i-lucide-languages', onSelect: () => runWritingAssistant('translate-ko') },
|
|
661
|
+
{ label: t('editor.ai.translateAll'), icon: 'i-lucide-languages', onSelect: () => translateAllLocales() },
|
|
657
662
|
],
|
|
658
663
|
[
|
|
659
664
|
{ label: t('editor.ai.title'), icon: 'i-lucide-heading-1', onSelect: () => runWritingAssistant('title') },
|
|
@@ -684,6 +689,7 @@ async function runWritingAssistant(action: WritingAssistantAction, customInstruc
|
|
|
684
689
|
const editor = resolveEditor()
|
|
685
690
|
if (!editor || aiRunning.value) return
|
|
686
691
|
aiError.value = ''
|
|
692
|
+
aiNotice.value = ''
|
|
687
693
|
|
|
688
694
|
const { from, to } = editor.state.selection
|
|
689
695
|
const hasSelection = from !== to
|
|
@@ -717,6 +723,60 @@ async function runWritingAssistant(action: WritingAssistantAction, customInstruc
|
|
|
717
723
|
}
|
|
718
724
|
}
|
|
719
725
|
|
|
726
|
+
function currentBodyContent(): string {
|
|
727
|
+
return isImageEditor.value && coverSlots.value.length
|
|
728
|
+
? withCoverImagesInContent(content.value, coverUrls())
|
|
729
|
+
: content.value
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function currentStoredTitle(body: string): string {
|
|
733
|
+
return isImageEditor.value ? titleFromBody(body) : title.value.trim()
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function translationKey(body: string, storedTitle: string): string {
|
|
737
|
+
return `${storedTitle}\n${hashText(body)}`
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
async function translateAllLocales() {
|
|
741
|
+
if (aiRunning.value) return
|
|
742
|
+
aiError.value = ''
|
|
743
|
+
aiNotice.value = ''
|
|
744
|
+
const body = currentBodyContent()
|
|
745
|
+
const storedTitle = currentStoredTitle(body)
|
|
746
|
+
const summary = buildPostPreview(body, isImageEditor.value ? [] : attachments.value).summary
|
|
747
|
+
if (!storedTitle.trim() && !summary.trim() && !plainTextFromHtml(body)) {
|
|
748
|
+
aiError.value = t('editor.ai.translateEmpty')
|
|
749
|
+
return
|
|
750
|
+
}
|
|
751
|
+
aiRunning.value = true
|
|
752
|
+
try {
|
|
753
|
+
const translations = await translatePost({
|
|
754
|
+
source: locale.value,
|
|
755
|
+
title: storedTitle,
|
|
756
|
+
summary,
|
|
757
|
+
content: plainTextFromHtml(body) ? body : '',
|
|
758
|
+
})
|
|
759
|
+
if (props.postId) {
|
|
760
|
+
await savePostTranslations(props.postId, translations)
|
|
761
|
+
pendingTranslations.value = null
|
|
762
|
+
pendingTranslationKey.value = ''
|
|
763
|
+
aiNotice.value = t('editor.ai.translateSaved')
|
|
764
|
+
}
|
|
765
|
+
else {
|
|
766
|
+
pendingTranslations.value = translations
|
|
767
|
+
pendingTranslationKey.value = translationKey(body, storedTitle)
|
|
768
|
+
aiNotice.value = t('editor.ai.translatePending')
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
catch (cause) {
|
|
772
|
+
console.warn('[memi-board] post translation failed', cause)
|
|
773
|
+
aiError.value = cause instanceof Error ? cause.message : t('editor.ai.failed')
|
|
774
|
+
}
|
|
775
|
+
finally {
|
|
776
|
+
aiRunning.value = false
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
720
780
|
function closeAiPreview() {
|
|
721
781
|
aiPreviewOpen.value = false
|
|
722
782
|
aiPreviewPending.value = null
|
|
@@ -982,15 +1042,19 @@ async function handleSubmit() {
|
|
|
982
1042
|
}
|
|
983
1043
|
|
|
984
1044
|
submitHint.value = t('editor.hint.saving')
|
|
985
|
-
const bodyContent =
|
|
986
|
-
|
|
987
|
-
|
|
1045
|
+
const bodyContent = currentBodyContent()
|
|
1046
|
+
const storedTitle = currentStoredTitle(bodyContent)
|
|
1047
|
+
const translations = pendingTranslations.value
|
|
1048
|
+
&& pendingTranslationKey.value === translationKey(bodyContent, storedTitle)
|
|
1049
|
+
? pendingTranslations.value
|
|
1050
|
+
: undefined
|
|
988
1051
|
const payload = {
|
|
989
1052
|
title: imageBoard ? '' : title.value.trim(),
|
|
990
1053
|
content: bodyContent,
|
|
991
1054
|
tags: tags.value.map(t => t.trim()).filter(Boolean),
|
|
992
1055
|
// 이미지 보드: 파일 첨부 없음 (커버는 본문 앞 img 로 저장)
|
|
993
1056
|
attachments: imageBoard ? [] : attachments.value,
|
|
1057
|
+
...(translations ? { translations } : {}),
|
|
994
1058
|
}
|
|
995
1059
|
|
|
996
1060
|
if (props.postId) {
|
|
@@ -1290,6 +1354,13 @@ async function handleSubmit() {
|
|
|
1290
1354
|
<UIcon name="i-lucide-circle-alert" class="size-3.5 shrink-0" />
|
|
1291
1355
|
{{ aiError }}
|
|
1292
1356
|
</p>
|
|
1357
|
+
<p
|
|
1358
|
+
v-else-if="aiNotice"
|
|
1359
|
+
class="flex items-center gap-2 border-t border-default px-4 py-2 text-xs text-primary"
|
|
1360
|
+
>
|
|
1361
|
+
<UIcon name="i-lucide-languages" class="size-3.5 shrink-0" />
|
|
1362
|
+
{{ aiNotice }}
|
|
1363
|
+
</p>
|
|
1293
1364
|
|
|
1294
1365
|
<p
|
|
1295
1366
|
v-if="imageUploading && !isImageEditor"
|
|
@@ -64,12 +64,12 @@ const emit = defineEmits<{ select: [post: PostModel], 'update:view': [view: Boar
|
|
|
64
64
|
const { boardDescription, getBoard } = useMemiBoardSettings()
|
|
65
65
|
const resolvedBoardId = computed(() => props.boardId || props.category || '')
|
|
66
66
|
// 보드 설정 언어 → 호스트 언어 순. 자식 컴포넌트도 이 언어를 따른다.
|
|
67
|
-
const { t, dir } = useMemiBoardI18n({ boardLocale: () => getBoard(resolvedBoardId.value)?.locale })
|
|
67
|
+
const { t, dir, locale } = useMemiBoardI18n({ boardLocale: () => getBoard(resolvedBoardId.value)?.locale })
|
|
68
68
|
const introductionText = computed(() => props.introduction ?? t('list.introduction'))
|
|
69
69
|
const listDescription = computed(() => {
|
|
70
70
|
const explicit = props.description?.trim()
|
|
71
71
|
if (explicit) return explicit
|
|
72
|
-
if (resolvedBoardId.value) return boardDescription(resolvedBoardId.value)
|
|
72
|
+
if (resolvedBoardId.value) return boardDescription(resolvedBoardId.value, locale.value)
|
|
73
73
|
return undefined
|
|
74
74
|
})
|
|
75
75
|
const { posts, postsPending, hasMore, loadingMore, loadError, loadMore } = useMemiBoardPostList(
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { resolveComponent } from 'vue'
|
|
3
3
|
import type { PostModel } from 'memi-board/runtime'
|
|
4
|
-
import { useMemiBoardI18n, useMemiBoardSettings } from 'memi-board/runtime'
|
|
4
|
+
import { displayPostSummary, displayPostTitle, useMemiBoardI18n, useMemiBoardSettings } from 'memi-board/runtime'
|
|
5
5
|
import MemiBoardAuthorMenu from './AuthorMenu.vue'
|
|
6
6
|
|
|
7
7
|
withDefaults(defineProps<{
|
|
@@ -15,7 +15,17 @@ withDefaults(defineProps<{
|
|
|
15
15
|
}>(), { showCategory: true })
|
|
16
16
|
const emit = defineEmits<{ select: [post: PostModel] }>()
|
|
17
17
|
const { categoryLabel } = useMemiBoardSettings()
|
|
18
|
-
const { t, formatRelativeDate, formatTimestampDetails } = useMemiBoardI18n()
|
|
18
|
+
const { t, locale, formatRelativeDate, formatTimestampDetails } = useMemiBoardI18n()
|
|
19
|
+
|
|
20
|
+
function titleOf(post: PostModel) {
|
|
21
|
+
return displayPostTitle(post, locale.value).trim() || displayPostSummary(post, locale.value).trim() || t('list.photo')
|
|
22
|
+
}
|
|
23
|
+
function summaryOf(post: PostModel) {
|
|
24
|
+
const summary = displayPostSummary(post, locale.value).trim()
|
|
25
|
+
const title = displayPostTitle(post, locale.value).trim()
|
|
26
|
+
if (!summary || summary === title) return ''
|
|
27
|
+
return summary
|
|
28
|
+
}
|
|
19
29
|
const NuxtLink = resolveComponent('NuxtLink')
|
|
20
30
|
|
|
21
31
|
function image(post: PostModel): string | undefined {
|
|
@@ -58,7 +68,7 @@ function image(post: PostModel): string | undefined {
|
|
|
58
68
|
class="mt-0.5 shrink-0"
|
|
59
69
|
/>
|
|
60
70
|
<h3 class="min-w-0 line-clamp-2 break-words text-sm font-medium leading-snug text-inherit sm:text-base">
|
|
61
|
-
{{
|
|
71
|
+
{{ titleOf(post) }}
|
|
62
72
|
</h3>
|
|
63
73
|
<span
|
|
64
74
|
v-if="(post.commentCount ?? 0) > 0"
|
|
@@ -69,10 +79,10 @@ function image(post: PostModel): string | undefined {
|
|
|
69
79
|
</div>
|
|
70
80
|
</div>
|
|
71
81
|
<p
|
|
72
|
-
v-if="post
|
|
82
|
+
v-if="summaryOf(post)"
|
|
73
83
|
class="min-w-0 line-clamp-2 break-words whitespace-pre-line text-sm leading-snug text-muted"
|
|
74
84
|
>
|
|
75
|
-
{{ post
|
|
85
|
+
{{ summaryOf(post) }}
|
|
76
86
|
</p>
|
|
77
87
|
</div>
|
|
78
88
|
|
|
@@ -82,7 +92,7 @@ function image(post: PostModel): string | undefined {
|
|
|
82
92
|
>
|
|
83
93
|
<img
|
|
84
94
|
:src="image(post)"
|
|
85
|
-
:alt="post
|
|
95
|
+
:alt="titleOf(post)"
|
|
86
96
|
class="h-full w-full object-cover"
|
|
87
97
|
>
|
|
88
98
|
</div>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { resolveComponent } from 'vue'
|
|
3
3
|
import type { PostModel } from 'memi-board/runtime'
|
|
4
|
-
import { useMemiBoardI18n, useMemiBoardSettings } from 'memi-board/runtime'
|
|
4
|
+
import { displayPostSummary, displayPostTitle, useMemiBoardI18n, useMemiBoardSettings } from 'memi-board/runtime'
|
|
5
5
|
import MemiBoardAuthorMenu from './AuthorMenu.vue'
|
|
6
6
|
|
|
7
7
|
withDefaults(defineProps<{
|
|
@@ -15,7 +15,11 @@ withDefaults(defineProps<{
|
|
|
15
15
|
}>(), { showCategory: true })
|
|
16
16
|
const emit = defineEmits<{ select: [post: PostModel] }>()
|
|
17
17
|
const { categoryLabel } = useMemiBoardSettings()
|
|
18
|
-
const { t, formatRelativeDate, formatTimestampDetails } = useMemiBoardI18n()
|
|
18
|
+
const { t, locale, formatRelativeDate, formatTimestampDetails } = useMemiBoardI18n()
|
|
19
|
+
|
|
20
|
+
function titleOf(post: PostModel) {
|
|
21
|
+
return displayPostTitle(post, locale.value).trim() || displayPostSummary(post, locale.value).trim() || t('list.photo')
|
|
22
|
+
}
|
|
19
23
|
const NuxtLink = resolveComponent('NuxtLink')
|
|
20
24
|
|
|
21
25
|
function thumb(post: PostModel): string | undefined {
|
|
@@ -44,7 +48,7 @@ function thumb(post: PostModel): string | undefined {
|
|
|
44
48
|
class="shrink-0"
|
|
45
49
|
/>
|
|
46
50
|
<h3 class="min-w-0 truncate text-sm font-medium leading-snug text-inherit transition-colors group-hover:text-primary">
|
|
47
|
-
{{
|
|
51
|
+
{{ titleOf(post) }}
|
|
48
52
|
</h3>
|
|
49
53
|
<span
|
|
50
54
|
v-if="(post.commentCount ?? 0) > 0"
|
|
@@ -113,7 +117,7 @@ function thumb(post: PostModel): string | undefined {
|
|
|
113
117
|
>
|
|
114
118
|
<img
|
|
115
119
|
:src="thumb(post)"
|
|
116
|
-
:alt="
|
|
120
|
+
:alt="titleOf(post)"
|
|
117
121
|
class="size-full object-cover"
|
|
118
122
|
>
|
|
119
123
|
</component>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { resolveComponent } from 'vue'
|
|
3
3
|
import type { PostModel } from 'memi-board/runtime'
|
|
4
|
-
import { useMemiBoardI18n } from 'memi-board/runtime'
|
|
4
|
+
import { displayPostSummary, displayPostTitle, useMemiBoardI18n } from 'memi-board/runtime'
|
|
5
5
|
|
|
6
6
|
defineProps<{
|
|
7
7
|
posts: PostModel[]
|
|
@@ -10,7 +10,14 @@ defineProps<{
|
|
|
10
10
|
}>()
|
|
11
11
|
const emit = defineEmits<{ select: [post: PostModel] }>()
|
|
12
12
|
const NuxtLink = resolveComponent('NuxtLink')
|
|
13
|
-
const { t } = useMemiBoardI18n()
|
|
13
|
+
const { t, locale } = useMemiBoardI18n()
|
|
14
|
+
|
|
15
|
+
function titleOf(post: PostModel) {
|
|
16
|
+
return displayPostTitle(post, locale.value).trim()
|
|
17
|
+
}
|
|
18
|
+
function altOf(post: PostModel) {
|
|
19
|
+
return titleOf(post) || displayPostSummary(post, locale.value).trim() || t('common.label.image')
|
|
20
|
+
}
|
|
14
21
|
|
|
15
22
|
function image(post: PostModel): string | undefined {
|
|
16
23
|
return post.previewImage || post.attachments?.find(item => item.type.startsWith('image/'))?.url
|
|
@@ -30,7 +37,7 @@ function image(post: PostModel): string | undefined {
|
|
|
30
37
|
<img
|
|
31
38
|
v-if="image(post)"
|
|
32
39
|
:src="image(post)"
|
|
33
|
-
:alt="
|
|
40
|
+
:alt="altOf(post)"
|
|
34
41
|
class="size-full object-cover transition duration-300 group-hover:scale-105"
|
|
35
42
|
>
|
|
36
43
|
<div
|
|
@@ -47,7 +54,7 @@ function image(post: PostModel): string | undefined {
|
|
|
47
54
|
|
|
48
55
|
<!-- 좌측 상단: 초안 표시 + 제목(있을 때만) + 댓글수. 이미지 보드는 제목 없이 썸네일 중심 -->
|
|
49
56
|
<div
|
|
50
|
-
v-if="post.isPublished === false || post
|
|
57
|
+
v-if="post.isPublished === false || titleOf(post) || (post.commentCount ?? 0) > 0"
|
|
51
58
|
class="absolute left-2.5 top-2.5 right-14 flex min-w-0 items-start gap-1.5 sm:left-3 sm:top-3"
|
|
52
59
|
>
|
|
53
60
|
<UBadge
|
|
@@ -59,10 +66,10 @@ function image(post: PostModel): string | undefined {
|
|
|
59
66
|
class="shrink-0"
|
|
60
67
|
/>
|
|
61
68
|
<h3
|
|
62
|
-
v-if="post
|
|
69
|
+
v-if="titleOf(post)"
|
|
63
70
|
class="min-w-0 line-clamp-2 text-sm font-medium leading-snug text-white drop-shadow-sm"
|
|
64
71
|
>
|
|
65
|
-
{{ post
|
|
72
|
+
{{ titleOf(post) }}
|
|
66
73
|
</h3>
|
|
67
74
|
<span
|
|
68
75
|
v-if="(post.commentCount ?? 0) > 0"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { resolveComponent } from 'vue'
|
|
3
3
|
import type { PostModel } from 'memi-board/runtime'
|
|
4
|
-
import { useMemiBoardI18n, videoListCoverUrl } from 'memi-board/runtime'
|
|
4
|
+
import { displayPostSummary, displayPostTitle, useMemiBoardI18n, videoListCoverUrl } from 'memi-board/runtime'
|
|
5
5
|
import MemiBoardAuthorMenu from './AuthorMenu.vue'
|
|
6
6
|
|
|
7
7
|
defineProps<{
|
|
@@ -14,7 +14,16 @@ defineProps<{
|
|
|
14
14
|
}>()
|
|
15
15
|
const emit = defineEmits<{ select: [post: PostModel] }>()
|
|
16
16
|
const NuxtLink = resolveComponent('NuxtLink')
|
|
17
|
-
const { t, formatRelativeDate, formatTimestampDetails } = useMemiBoardI18n()
|
|
17
|
+
const { t, locale, formatRelativeDate, formatTimestampDetails } = useMemiBoardI18n()
|
|
18
|
+
|
|
19
|
+
function titleOf(post: PostModel) {
|
|
20
|
+
return displayPostTitle(post, locale.value).trim()
|
|
21
|
+
}
|
|
22
|
+
function summaryOf(post: PostModel) {
|
|
23
|
+
const summary = displayPostSummary(post, locale.value).trim()
|
|
24
|
+
if (!summary || summary === titleOf(post)) return ''
|
|
25
|
+
return summary
|
|
26
|
+
}
|
|
18
27
|
|
|
19
28
|
/** 영상 목록: YouTube URL 이 있으면 썸네일 우선, 그다음 본문/첨부 이미지. */
|
|
20
29
|
function cover(post: PostModel): string | undefined {
|
|
@@ -37,7 +46,7 @@ function cover(post: PostModel): string | undefined {
|
|
|
37
46
|
@click="!postTo(post) && emit('select', post)"
|
|
38
47
|
>
|
|
39
48
|
<div class="relative aspect-video overflow-hidden bg-elevated">
|
|
40
|
-
<img v-if="cover(post)" :src="cover(post)" :alt="post
|
|
49
|
+
<img v-if="cover(post)" :src="cover(post)" :alt="titleOf(post)" class="size-full object-cover">
|
|
41
50
|
<div v-else class="flex size-full items-center justify-center text-muted"><UIcon name="i-lucide-video" class="size-10" /></div>
|
|
42
51
|
<span class="absolute inset-0 flex items-center justify-center bg-black/10">
|
|
43
52
|
<span class="flex size-12 items-center justify-center rounded-full bg-black/70 text-white shadow"><UIcon name="i-lucide-play" class="ml-0.5 size-6 fill-current" /></span>
|
|
@@ -53,7 +62,7 @@ function cover(post: PostModel): string | undefined {
|
|
|
53
62
|
size="sm"
|
|
54
63
|
class="mt-0.5 shrink-0"
|
|
55
64
|
/>
|
|
56
|
-
<h3 class="min-w-0 line-clamp-2 font-medium text-inherit">{{ post
|
|
65
|
+
<h3 class="min-w-0 line-clamp-2 font-medium text-inherit">{{ titleOf(post) }}</h3>
|
|
57
66
|
<span
|
|
58
67
|
v-if="(post.commentCount ?? 0) > 0"
|
|
59
68
|
class="mt-0.5 inline-flex shrink-0 items-center rounded-full bg-primary/10 px-1.5 py-px text-[11px] font-semibold tabular-nums text-primary"
|
|
@@ -61,7 +70,7 @@ function cover(post: PostModel): string | undefined {
|
|
|
61
70
|
{{ post.commentCount }}
|
|
62
71
|
</span>
|
|
63
72
|
</div>
|
|
64
|
-
<p v-if="post
|
|
73
|
+
<p v-if="summaryOf(post)" class="mt-2 line-clamp-2 whitespace-pre-line text-sm text-muted">{{ summaryOf(post) }}</p>
|
|
65
74
|
</div>
|
|
66
75
|
</component>
|
|
67
76
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed } from 'vue'
|
|
3
3
|
import type { BoardSection, CommentModel, PostModel } from 'memi-board/runtime'
|
|
4
|
-
import { miniMoreLink, sectionColClass, sectionMinHeight, useMemiBoardI18n } from 'memi-board/runtime'
|
|
4
|
+
import { displayStoredField, miniMoreLink, sectionColClass, sectionMinHeight, useMemiBoardI18n } from 'memi-board/runtime'
|
|
5
5
|
import MemiBoardMiniComments from './MiniComments.vue'
|
|
6
6
|
import MemiBoardMiniList from './MiniList.vue'
|
|
7
7
|
import MemiBoardMiniPost from './MiniPost.vue'
|
|
@@ -13,7 +13,14 @@ const props = defineProps<{
|
|
|
13
13
|
getMoreLink?: (section: BoardSection) => string | undefined
|
|
14
14
|
}>()
|
|
15
15
|
|
|
16
|
-
const { t } = useMemiBoardI18n()
|
|
16
|
+
const { t, locale } = useMemiBoardI18n()
|
|
17
|
+
const title = computed(() => displayStoredField(
|
|
18
|
+
props.section.i18n,
|
|
19
|
+
props.section.i18nSource,
|
|
20
|
+
'title',
|
|
21
|
+
props.section.title,
|
|
22
|
+
locale.value,
|
|
23
|
+
))
|
|
17
24
|
const moreTo = computed(() => props.getMoreLink?.(props.section) ?? miniMoreLink(props.section))
|
|
18
25
|
</script>
|
|
19
26
|
|
|
@@ -30,7 +37,7 @@ const moreTo = computed(() => props.getMoreLink?.(props.section) ?? miniMoreLink
|
|
|
30
37
|
v-if="section.showTitle"
|
|
31
38
|
class="min-w-0 truncate text-sm font-semibold text-highlighted"
|
|
32
39
|
>
|
|
33
|
-
{{
|
|
40
|
+
{{ title }}
|
|
34
41
|
</h2>
|
|
35
42
|
<span v-else class="min-w-0" />
|
|
36
43
|
<UButton
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed, onMounted, ref, watch } from 'vue'
|
|
3
|
-
import { MEMI_BOARD_LOCALES, slugify, useMemiBoardAuth, useMemiBoardI18n, useMemiBoardSettings, useMemiBoardUsers } from 'memi-board/runtime'
|
|
3
|
+
import { boardCopyIsTranslated, 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
5
|
import MemiBoardOptionCards from './OptionCards.vue'
|
|
6
6
|
|
|
@@ -58,7 +58,7 @@ function fromLocaleOption(value: string) {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
const { isAdmin, rolePending } = useMemiBoardAuth()
|
|
61
|
-
const { categories, settingsPending, saveCategory, saveCategories, deleteCategory } = useMemiBoardSettings()
|
|
61
|
+
const { categories, settingsPending, saveCategory, saveCategories, deleteCategory, translateBoard } = useMemiBoardSettings()
|
|
62
62
|
const canManage = computed(() => isAdmin.value || props.authorized)
|
|
63
63
|
// users 전체 목록은 rules상 board-role 관리자만 읽을 수 있다 — "허용 스태프" 지정 권한이
|
|
64
64
|
// 있을 때만(기본은 board-role 관리자) 쿼리를 시작한다. canManage(호스트 authorized 포함)로
|
|
@@ -82,6 +82,8 @@ const pending = computed(() => !mounted.value || rolePending.value || settingsPe
|
|
|
82
82
|
const draft = ref<BoardCategory[]>([])
|
|
83
83
|
const newLabel = ref('')
|
|
84
84
|
const savingId = ref<string | null>(null)
|
|
85
|
+
const translatingId = ref<string | null>(null)
|
|
86
|
+
const translatedDraftIds = ref(new Set<string>())
|
|
85
87
|
const savedId = ref<string | null>(null)
|
|
86
88
|
const deletingId = ref<string | null>(null)
|
|
87
89
|
const ordering = ref(false)
|
|
@@ -104,6 +106,7 @@ watch([categories, settingsPending], ([list, loading]) => {
|
|
|
104
106
|
allowedStaffUids: category.allowedStaffUids ?? [],
|
|
105
107
|
allowedManagerUids: category.allowedManagerUids ?? [],
|
|
106
108
|
locale: category.locale ?? '',
|
|
109
|
+
autoTranslate: category.autoTranslate === true,
|
|
107
110
|
}))
|
|
108
111
|
}
|
|
109
112
|
}, { immediate: true })
|
|
@@ -174,16 +177,55 @@ function categoryTo(id: string) {
|
|
|
174
177
|
return `${props.categoryBase.replace(/\/$/, '')}/${encodeURIComponent(id)}`
|
|
175
178
|
}
|
|
176
179
|
|
|
180
|
+
async function translateCopy(category: BoardCategory) {
|
|
181
|
+
if (!category.label.trim() || translatingId.value || savingId.value) return
|
|
182
|
+
const snapshot = { ...category }
|
|
183
|
+
translatingId.value = category.id
|
|
184
|
+
savedId.value = null
|
|
185
|
+
error.value = ''
|
|
186
|
+
try {
|
|
187
|
+
const translated = await translateBoard(snapshot)
|
|
188
|
+
if (!draft.value.includes(category) || category.label !== snapshot.label || category.description !== snapshot.description) return
|
|
189
|
+
Object.assign(category, { label: translated.label, description: translated.description, i18n: translated.i18n, i18nSource: translated.i18nSource })
|
|
190
|
+
translatedDraftIds.value.add(category.id)
|
|
191
|
+
}
|
|
192
|
+
catch (cause) {
|
|
193
|
+
console.warn('[memi-board] board translation failed', cause)
|
|
194
|
+
error.value = t('settings.autoTranslate.manualFailed')
|
|
195
|
+
}
|
|
196
|
+
finally {
|
|
197
|
+
translatingId.value = null
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
177
201
|
async function save(category: BoardCategory, index: number) {
|
|
202
|
+
if (translatingId.value || savingId.value) return
|
|
178
203
|
error.value = ''
|
|
179
204
|
savedId.value = null
|
|
180
205
|
savingId.value = category.id
|
|
206
|
+
category.autoTranslate = category.autoTranslate === true
|
|
207
|
+
let translateFailed = false
|
|
208
|
+
if (category.autoTranslate) {
|
|
209
|
+
try {
|
|
210
|
+
const translated = await translateBoard(category)
|
|
211
|
+
category.i18n = translated.i18n
|
|
212
|
+
category.i18nSource = translated.i18nSource
|
|
213
|
+
category.label = translated.label
|
|
214
|
+
category.description = translated.description
|
|
215
|
+
}
|
|
216
|
+
catch (cause) {
|
|
217
|
+
console.warn('[memi-board] board translation failed', cause)
|
|
218
|
+
translateFailed = true
|
|
219
|
+
}
|
|
220
|
+
}
|
|
181
221
|
try {
|
|
182
222
|
const order = props.categoryId ? (category.order ?? index) : index
|
|
183
223
|
await saveCategory(category, order)
|
|
184
224
|
category.order = order
|
|
185
225
|
savedId.value = category.id
|
|
226
|
+
translatedDraftIds.value.delete(category.id)
|
|
186
227
|
emit('saved', draft.value)
|
|
228
|
+
if (translateFailed) error.value = t('settings.autoTranslate.failed')
|
|
187
229
|
}
|
|
188
230
|
catch (cause) {
|
|
189
231
|
error.value = cause instanceof Error ? cause.message : t('settings.error.saveFailed')
|
|
@@ -298,18 +340,41 @@ async function runDeleteAll() {
|
|
|
298
340
|
</div>
|
|
299
341
|
<div class="grid gap-2 sm:grid-cols-[9rem_1fr] sm:items-center">
|
|
300
342
|
<div><p class="text-sm font-medium">{{ t('settings.label.label') }}</p><p class="text-xs text-muted">{{ t('settings.label.help') }}</p></div>
|
|
301
|
-
<
|
|
343
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
344
|
+
<UInput v-model="category.label" class="min-w-0 flex-1" :disabled="translatingId === category.id" @update:model-value="savedId = null" />
|
|
345
|
+
<UButton
|
|
346
|
+
:label="t('settings.autoTranslate.action')"
|
|
347
|
+
icon="i-lucide-languages"
|
|
348
|
+
color="neutral"
|
|
349
|
+
variant="outline"
|
|
350
|
+
:loading="translatingId === category.id"
|
|
351
|
+
:disabled="savingId !== null || translatingId !== null || !category.label.trim()"
|
|
352
|
+
@click="translateCopy(category)"
|
|
353
|
+
/>
|
|
354
|
+
</div>
|
|
302
355
|
</div>
|
|
303
356
|
<div class="grid gap-2 sm:grid-cols-[9rem_1fr] sm:items-start">
|
|
304
357
|
<div><p class="text-sm font-medium">{{ t('settings.description.label') }}</p><p class="text-xs text-muted">{{ t('settings.description.help') }}</p></div>
|
|
305
|
-
<
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
358
|
+
<div class="flex flex-col gap-2">
|
|
359
|
+
<UTextarea
|
|
360
|
+
v-model="category.description"
|
|
361
|
+
:disabled="translatingId === category.id"
|
|
362
|
+
:rows="3"
|
|
363
|
+
autoresize
|
|
364
|
+
:maxrows="6"
|
|
365
|
+
:placeholder="t('settings.description.placeholder')"
|
|
366
|
+
@update:model-value="savedId = null"
|
|
367
|
+
/>
|
|
368
|
+
<div class="flex items-center justify-between gap-3">
|
|
369
|
+
<div>
|
|
370
|
+
<p class="text-sm font-medium">{{ t('settings.autoTranslate.label') }}</p>
|
|
371
|
+
<p class="text-xs text-muted">{{ t('settings.autoTranslate.help') }}</p>
|
|
372
|
+
</div>
|
|
373
|
+
<USwitch v-model="category.autoTranslate" size="sm" @update:model-value="savedId = null" />
|
|
374
|
+
</div>
|
|
375
|
+
<p class="text-xs text-muted">{{ t('settings.autoTranslate.manualHelp') }}</p>
|
|
376
|
+
<p v-if="boardCopyIsTranslated(category)" class="text-xs text-primary">{{ t(translatedDraftIds.has(category.id) ? 'settings.autoTranslate.pending' : 'settings.autoTranslate.ready') }}</p>
|
|
377
|
+
</div>
|
|
313
378
|
</div>
|
|
314
379
|
<div v-if="canManageStaffAssignment" class="flex flex-col gap-2">
|
|
315
380
|
<div>
|
|
@@ -378,6 +443,7 @@ async function runDeleteAll() {
|
|
|
378
443
|
icon="i-lucide-save"
|
|
379
444
|
size="sm"
|
|
380
445
|
:loading="savingId === category.id"
|
|
446
|
+
:disabled="translatingId !== null || savingId !== null"
|
|
381
447
|
@click="save(category, index)"
|
|
382
448
|
/>
|
|
383
449
|
</div>
|
|
@@ -90,9 +90,9 @@ export async function useMemiBoardPostSeo(opts?: {
|
|
|
90
90
|
|
|
91
91
|
// await 하지 않고 먼저 등록 → data ref 확보 후 메타 연결
|
|
92
92
|
const postAsync = useAsyncData(
|
|
93
|
-
() => `memi-board-seo-post-${category.value}-${slug.value}`,
|
|
94
|
-
() => fetchPublicPostForSeo(category.value, slug.value, seoDb),
|
|
95
|
-
{ watch: [category, slug], server: true, lazy: false },
|
|
93
|
+
() => `memi-board-seo-post-${category.value}-${slug.value}-${locale.value}`,
|
|
94
|
+
() => fetchPublicPostForSeo(category.value, slug.value, seoDb, locale.value),
|
|
95
|
+
{ watch: [category, slug, locale], server: true, lazy: false },
|
|
96
96
|
)
|
|
97
97
|
const post = computed(() => postAsync.data.value ?? null)
|
|
98
98
|
|
|
@@ -443,6 +443,10 @@ var L = {
|
|
|
443
443
|
summarize: "짧게 요약하기",
|
|
444
444
|
translateEn: "영어로 번역",
|
|
445
445
|
translateKo: "한국어로 번역",
|
|
446
|
+
translateAll: "10개 언어로 번역해 저장",
|
|
447
|
+
translateSaved: "10개 언어로 저장했습니다. 다른 언어로 보면 번역이 보입니다.",
|
|
448
|
+
translatePending: "10개 언어 번역을 준비했습니다. 글을 저장하면 함께 기록됩니다.",
|
|
449
|
+
translateEmpty: "번역할 제목이나 본문을 먼저 입력해 주세요.",
|
|
446
450
|
title: "본문에서 제목 만들기",
|
|
447
451
|
custom: "커스텀 스타일로 바꾸기…",
|
|
448
452
|
failed: "AI 작업에 실패했습니다. 다시 시도해 주세요."
|
|
@@ -739,6 +743,16 @@ var L = {
|
|
|
739
743
|
help: "비우면 사이트 언어를 따릅니다. 고르면 이 게시판은 항상 그 언어로 보입니다.",
|
|
740
744
|
auto: "자동 (사이트 언어 따름)"
|
|
741
745
|
},
|
|
746
|
+
autoTranslate: {
|
|
747
|
+
action: "10개 언어로 번역",
|
|
748
|
+
manualHelp: "표시 라벨과 설명을 함께 번역합니다. 번역 후 저장 버튼을 눌러 적용하세요.",
|
|
749
|
+
pending: "10개 언어 번역이 준비되었습니다. 저장 버튼을 눌러 적용하세요.",
|
|
750
|
+
manualFailed: "번역하지 못했습니다. 입력한 내용은 유지됩니다. 다시 시도해 주세요.",
|
|
751
|
+
label: "저장할 때 10개 언어로 번역",
|
|
752
|
+
help: "켜 두면 이름과 설명을 10개 언어로 번역합니다. 꺼져 있으면 번역하지 않습니다. 문구가 그대로면 다시 번역하지 않습니다.",
|
|
753
|
+
failed: "설정은 저장했지만 10개 언어 번역에 실패했습니다. 다시 저장해 주세요.",
|
|
754
|
+
ready: "이름과 설명이 10개 언어로 저장되어 있습니다."
|
|
755
|
+
},
|
|
742
756
|
action: {
|
|
743
757
|
moveDown: "아래로",
|
|
744
758
|
goToBoard: "게시판으로 이동",
|
|
@@ -819,6 +833,9 @@ var L = {
|
|
|
819
833
|
contentRequired: "AI로 다듬을 내용을 먼저 입력해 주세요.",
|
|
820
834
|
customRequired: "어떻게 바꿀지 지시문을 입력해 주세요.",
|
|
821
835
|
emptyResult: "AI가 결과를 만들지 못했습니다. 다시 시도해 주세요.",
|
|
836
|
+
translateEmpty: "번역할 제목이나 본문을 먼저 입력해 주세요.",
|
|
837
|
+
translateTooLong: "본문이 {limit}자를 넘으면 10개 언어로 저장할 수 없습니다. 줄인 뒤 다시 시도해 주세요.",
|
|
838
|
+
translateIncomplete: "일부 언어 번역이 비었습니다. 다시 시도해 주세요.",
|
|
822
839
|
failed: "AI 작업에 실패했습니다. 다시 시도해 주세요.",
|
|
823
840
|
actions: {
|
|
824
841
|
proofread: "맞춤법 검사",
|
|
@@ -827,20 +844,21 @@ var L = {
|
|
|
827
844
|
summarize: "짧게 요약하기",
|
|
828
845
|
translateEn: "영어로 번역",
|
|
829
846
|
translateKo: "한국어로 번역",
|
|
847
|
+
translateAll: "10개 언어로 번역해 저장",
|
|
830
848
|
title: "본문에서 제목 만들기",
|
|
831
849
|
custom: "커스텀 스타일로 바꾸기…"
|
|
832
850
|
}
|
|
833
851
|
}
|
|
834
852
|
}, R = {
|
|
835
|
-
en: () => import("./en-
|
|
836
|
-
ja: () => import("./ja-
|
|
837
|
-
de: () => import("./de-
|
|
838
|
-
fr: () => import("./fr-
|
|
839
|
-
es: () => import("./es-
|
|
840
|
-
pt: () => import("./pt-
|
|
841
|
-
zh: () => import("./zh-
|
|
842
|
-
ar: () => import("./ar-
|
|
843
|
-
id: () => import("./id-
|
|
853
|
+
en: () => import("./en-Broh-9df.js"),
|
|
854
|
+
ja: () => import("./ja-D2dIWUse.js"),
|
|
855
|
+
de: () => import("./de-DQqSsBsZ.js"),
|
|
856
|
+
fr: () => import("./fr-CbXPRKeU.js"),
|
|
857
|
+
es: () => import("./es-D3eEYN6w.js"),
|
|
858
|
+
pt: () => import("./pt-BAuHdglU.js"),
|
|
859
|
+
zh: () => import("./zh-CMyzTdQ5.js"),
|
|
860
|
+
ar: () => import("./ar-D_6hknjh.js"),
|
|
861
|
+
id: () => import("./id-DxVqvPpo.js")
|
|
844
862
|
}, z = "__MEMI_BOARD_I18N__";
|
|
845
863
|
function B() {
|
|
846
864
|
let e = globalThis;
|
|
@@ -948,4 +966,4 @@ function _e(e, t = 60) {
|
|
|
948
966
|
//#endregion
|
|
949
967
|
export { ae as A, ie as B, h as C, ue as D, ce as E, C as F, te as G, re as H, b as I, y as L, x as M, T as N, oe as O, w as P, le as R, f as S, _ as T, v as U, E as V, ee as W, de as _, K as a, p as b, X as c, W as d, M as f, F as g, I as h, ge as i, S as j, se as k, fe as l, j as m, $ as n, Y as o, N as p, Z as r, J as s, _e as t, H as u, A as v, g as w, m as x, k as y, ne as z };
|
|
950
968
|
|
|
951
|
-
//# sourceMappingURL=slugify-
|
|
969
|
+
//# sourceMappingURL=slugify-B05Qykf_.js.map
|