memi-board 0.52.0 → 0.52.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.
@@ -0,0 +1,93 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from 'vue'
3
+ import { boardTranslationLocales, COMMENT_BODY_MAX_LENGTH, useMemiBoardI18n, useMemiBoardSettings, useMemiBoardWritingAssistant } from 'memi-board/runtime'
4
+ import type { CommentTranslationSet, WritingAssistantAction } from 'memi-board/runtime'
5
+
6
+ const props = defineProps<{ boardId: string, disabled?: boolean }>()
7
+ const body = defineModel<string>({ required: true })
8
+ const translations = defineModel<CommentTranslationSet | undefined>('translations')
9
+ const emit = defineEmits<{ busy: [value: boolean] }>()
10
+ const { t, locale, translationEnabled } = useMemiBoardI18n()
11
+ const { getBoard } = useMemiBoardSettings()
12
+ const { assist, translateComment } = useMemiBoardWritingAssistant({ locale })
13
+ const targets = computed(() => boardTranslationLocales(getBoard(props.boardId)))
14
+ const running = ref(false)
15
+ const error = ref('')
16
+ const notice = ref('')
17
+ const previewOpen = ref(false)
18
+ const before = ref('')
19
+ const result = ref('')
20
+ const customOpen = ref(false)
21
+ const instruction = ref('')
22
+ watch(body, () => { translations.value = undefined; notice.value = '' }, { flush: 'sync' })
23
+ const items = computed(() => [
24
+ [
25
+ ...(['proofread', 'polish', 'continue', 'summarize'] as const).map(action => ({ label: t(`editor.ai.${action}`), onSelect: () => run(action) })),
26
+ ],
27
+ ...(translationEnabled.value ? [[{ label: t('editor.ai.translateAll', { count: targets.value.length }), icon: 'i-lucide-languages', onSelect: translate }]] : []),
28
+ [{ label: t('editor.ai.custom'), onSelect: () => { instruction.value = ''; customOpen.value = true } }],
29
+ ])
30
+ function busy(value: boolean) { running.value = value; emit('busy', value) }
31
+ async function run(action: WritingAssistantAction) {
32
+ if (running.value || props.disabled || !body.value.trim()) return
33
+ before.value = body.value
34
+ error.value = ''; notice.value = ''
35
+ busy(true)
36
+ try {
37
+ const output = await assist({ action, content: before.value, selection: true, customInstruction: instruction.value })
38
+ result.value = action === 'continue' ? `${before.value}\n\n${output}` : output
39
+ if (result.value.length > COMMENT_BODY_MAX_LENGTH) throw new Error(t('comments.bodyTooLong', { max: COMMENT_BODY_MAX_LENGTH }))
40
+ previewOpen.value = true
41
+ }
42
+ catch (cause) { error.value = cause instanceof Error ? cause.message : t('editor.ai.failed') }
43
+ finally { busy(false) }
44
+ }
45
+ async function translate() {
46
+ if (running.value || props.disabled || !body.value.trim()) return
47
+ const source = body.value
48
+ error.value = ''; notice.value = ''
49
+ busy(true)
50
+ try {
51
+ const output = await translateComment({ body: source, targets: targets.value })
52
+ if (body.value === source) {
53
+ translations.value = output
54
+ notice.value = t('commentForm.translationPending', { count: targets.value.length })
55
+ }
56
+ }
57
+ catch (cause) { error.value = cause instanceof Error ? cause.message : t('editor.ai.failed') }
58
+ finally { busy(false) }
59
+ }
60
+ function apply() {
61
+ if (body.value === before.value) body.value = result.value
62
+ previewOpen.value = false
63
+ }
64
+ </script>
65
+
66
+ <template>
67
+ <div class="flex flex-wrap items-center gap-2">
68
+ <UDropdownMenu :items="items">
69
+ <UButton type="button" icon="i-lucide-sparkles" label="AI" size="sm" color="neutral" variant="ghost"
70
+ :aria-label="t('editor.aiAssistant')" :loading="running" :disabled="disabled || running || !body.trim()" />
71
+ </UDropdownMenu>
72
+ <span v-if="error" class="text-xs text-error" role="alert">{{ error }}</span>
73
+ <span v-else-if="notice" class="text-xs text-primary" role="status">{{ notice }}</span>
74
+ </div>
75
+ <UModal v-model:open="customOpen" :title="t('editor.ai.custom')">
76
+ <template #body><UTextarea v-model="instruction" :maxlength="200" /></template>
77
+ <template #footer>
78
+ <UButton type="button" :disabled="!instruction.trim()" :label="t('editor.apply')" @click="customOpen = false; run('custom')" />
79
+ </template>
80
+ </UModal>
81
+ <UModal v-model:open="previewOpen" :title="t('editor.aiPreview.title')">
82
+ <template #body>
83
+ <div class="grid gap-4 sm:grid-cols-2">
84
+ <div><p class="text-xs text-muted">{{ t('editor.aiPreview.before') }}</p><p class="whitespace-pre-wrap break-words text-sm">{{ before }}</p></div>
85
+ <div><p class="text-xs text-primary">{{ t('editor.aiPreview.after') }}</p><p class="whitespace-pre-wrap break-words text-sm">{{ result }}</p></div>
86
+ </div>
87
+ </template>
88
+ <template #footer>
89
+ <UButton type="button" color="neutral" variant="ghost" :label="t('common.action.cancel')" @click="previewOpen = false" />
90
+ <UButton type="button" :label="t('editor.apply')" :disabled="disabled || body !== before" @click="apply" />
91
+ </template>
92
+ </UModal>
93
+ </template>
@@ -1,6 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, ref, resolveComponent, toRef } from 'vue'
3
- import { useMemiBoardCommentFeed, useMemiBoardI18n } from 'memi-board/runtime'
3
+ import { displayCommentBody, useMemiBoardCommentFeed, useMemiBoardI18n } from 'memi-board/runtime'
4
4
  import type { CommentFeedSort, CommentModel } from 'memi-board/runtime'
5
5
 
6
6
  const NuxtLink = resolveComponent('NuxtLink')
@@ -18,7 +18,7 @@ const props = withDefaults(defineProps<{
18
18
 
19
19
  const emit = defineEmits<{ select: [comment: CommentModel] }>()
20
20
 
21
- const { t, formatRelativeDate } = useMemiBoardI18n()
21
+ const { t, locale, formatRelativeDate } = useMemiBoardI18n()
22
22
 
23
23
  const { comments, commentsPending, hasMore, loadingMore, loadError, loadMore } = useMemiBoardCommentFeed(
24
24
  toRef(props, 'sort'),
@@ -111,7 +111,7 @@ const emptyMessage = computed(() =>
111
111
  v-if="comment.replyToName"
112
112
  class="mr-1 text-primary"
113
113
  >@{{ comment.replyToName }}</span>
114
- {{ comment.body }}
114
+ {{ displayCommentBody(comment, locale) }}
115
115
  </p>
116
116
  <p
117
117
  v-if="(comment.likeCount ?? 0) > 0"
@@ -6,8 +6,9 @@ import { COMMENT_BODY_MAX_LENGTH, useMemiBoardComments } from 'memi-board/runtim
6
6
  import { useMemiBoardModeration } from 'memi-board/runtime'
7
7
  import { useMemiBoardSettings } from 'memi-board/runtime'
8
8
  import { useMemiBoardI18n } from 'memi-board/runtime'
9
- import type { Attachment, CommentModel } from 'memi-board/runtime'
9
+ import type { Attachment, CommentModel, CommentTranslationSet } from 'memi-board/runtime'
10
10
  import { useMemiBoardStorage } from 'memi-board/storage'
11
+ import MemiBoardCommentAi from './CommentAi.vue'
11
12
  import MemiBoardCommentImageField from './CommentImageField.vue'
12
13
 
13
14
  const props = defineProps<{ boardId: string, postId: string, parent?: CommentModel | null }>()
@@ -37,6 +38,8 @@ const deniedMessage = computed(() => {
37
38
  })
38
39
 
39
40
  const body = ref('')
41
+ const translations = ref<CommentTranslationSet>()
42
+ const aiBusy = ref(false)
40
43
  const draftId = ref(newCommentId())
41
44
  const image = ref<Attachment | null>(null)
42
45
  const imageField = ref<InstanceType<typeof MemiBoardCommentImageField> | null>(null)
@@ -56,7 +59,7 @@ async function handleCancel() {
56
59
  }
57
60
 
58
61
  async function handleSubmit() {
59
- if (submitting.value || imageBusy.value) return
62
+ if (submitting.value || imageBusy.value || aiBusy.value) return
60
63
  error.value = ''
61
64
  if (!body.value.trim()) return
62
65
  if (!user.value) {
@@ -83,6 +86,7 @@ async function handleSubmit() {
83
86
  const input = {
84
87
  id: draftId.value,
85
88
  body: body.value,
89
+ translations: translations.value,
86
90
  images: image.value ? [image.value] : [],
87
91
  authorUid: user.value.uid,
88
92
  authorName: user.value.displayName,
@@ -120,8 +124,11 @@ onUnmounted(() => {
120
124
  @paste="imageField?.handlePaste($event)"
121
125
  :placeholder="parent ? t('commentForm.replyTo', { name: parent.authorName || t('common.label.user') }) : t('commentForm.placeholder')"
122
126
  :rows="2"
127
+ :disabled="submitting || aiBusy || isWriteRestricted"
123
128
  :maxlength="COMMENT_BODY_MAX_LENGTH"
124
129
  />
130
+ <MemiBoardCommentAi v-model="body" v-model:translations="translations" :board-id="boardId"
131
+ :disabled="submitting || imageBusy || isWriteRestricted" @busy="aiBusy = $event" />
125
132
  <MemiBoardCommentImageField
126
133
  ref="imageField"
127
134
  v-model="image"
@@ -152,7 +159,7 @@ onUnmounted(() => {
152
159
  :label="t('common.action.cancel')"
153
160
  color="neutral"
154
161
  variant="ghost"
155
- :disabled="imageBusy"
162
+ :disabled="imageBusy || submitting || aiBusy"
156
163
  @click="handleCancel"
157
164
  />
158
165
  <UButton
@@ -162,7 +169,7 @@ onUnmounted(() => {
162
169
  size="sm"
163
170
  :label="parent ? t('commentForm.submitReply') : t('commentForm.submit')"
164
171
  :loading="submitting"
165
- :disabled="!body.trim() || isWriteRestricted || imageBusy"
172
+ :disabled="!body.trim() || isWriteRestricted || imageBusy || aiBusy"
166
173
  />
167
174
  </div>
168
175
  </form>
@@ -2,16 +2,18 @@
2
2
  import { imeSafeSubmitClick, imeSafeSubmitPointerDown } from 'memi-board/runtime'
3
3
  import { computed, inject, ref, watch } from 'vue'
4
4
  import { Timestamp } from 'firebase/firestore'
5
- import type { Attachment, CommentModel } from 'memi-board/runtime'
5
+ import type { Attachment, CommentModel, CommentTranslationSet } from 'memi-board/runtime'
6
6
  import { useMemiBoardStorage } from 'memi-board/storage'
7
7
  import {
8
8
  COMMENT_BODY_MAX_LENGTH,
9
+ displayCommentBody,
9
10
  memiBoardCommentLikesKey,
10
11
  useMemiBoardAuth,
11
12
  useMemiBoardComments,
12
13
  useMemiBoardI18n,
13
14
  useMemiBoardModeration,
14
15
  } from 'memi-board/runtime'
16
+ import MemiBoardCommentAi from './CommentAi.vue'
15
17
  import MemiBoardCommentImageField from './CommentImageField.vue'
16
18
 
17
19
  const props = defineProps<{
@@ -35,10 +37,14 @@ const { updateComment, setCommentBlinded } = useMemiBoardComments(
35
37
  )
36
38
  const { checkText } = useMemiBoardModeration()
37
39
  const { deleteAttachment } = useMemiBoardStorage()
38
- const { t, formatRelativeDate, formatTimestampDetails } = useMemiBoardI18n()
40
+ const { t, locale, formatRelativeDate, formatTimestampDetails } = useMemiBoardI18n()
39
41
  const commentLikes = inject(memiBoardCommentLikesKey, null)
40
42
 
41
43
  const editing = ref(false)
44
+ const translations = ref<CommentTranslationSet>()
45
+ const aiBusy = ref(false)
46
+ const localTranslations = ref<CommentTranslationSet>()
47
+ const displayedBody = computed(() => displayCommentBody({ ...props.comment, ...localTranslations.value, body: localBody.value }, locale.value))
42
48
  const editBody = ref(props.comment.body)
43
49
  const localBody = ref(props.comment.body)
44
50
  const localImage = ref<Attachment | null>(props.comment.images?.[0] ?? null)
@@ -58,6 +64,7 @@ const likePending = computed(() => commentLikes?.isPending(props.comment.id) ??
58
64
  const imageChanged = computed(() => (editImage.value?.path ?? '') !== (localImage.value?.path ?? ''))
59
65
 
60
66
  watch(() => props.comment.body, (body) => {
67
+ localTranslations.value = undefined
61
68
  localBody.value = body
62
69
  if (!editing.value) editBody.value = body
63
70
  })
@@ -81,6 +88,7 @@ function startEdit() {
81
88
  editBody.value = localBody.value
82
89
  editImage.value = localImage.value
83
90
  editError.value = ''
91
+ translations.value = undefined
84
92
  editing.value = true
85
93
  }
86
94
 
@@ -95,10 +103,10 @@ async function cancelEdit() {
95
103
  }
96
104
 
97
105
  async function saveEdit() {
98
- if (saving.value || imageBusy.value) return
106
+ if (saving.value || imageBusy.value || aiBusy.value) return
99
107
  const body = editBody.value.trim()
100
108
  if (!props.comment.id || !body) return
101
- if (body === localBody.value && !imageChanged.value) {
109
+ if (body === localBody.value && !imageChanged.value && !translations.value) {
102
110
  editing.value = false
103
111
  return
104
112
  }
@@ -117,7 +125,8 @@ async function saveEdit() {
117
125
  }
118
126
  const previous = localImage.value
119
127
  const next = editImage.value
120
- await updateComment(props.comment.id, body, next ? [next] : [])
128
+ await updateComment(props.comment.id, body, next ? [next] : [], translations.value)
129
+ localTranslations.value = translations.value
121
130
  localBody.value = body
122
131
  localImage.value = next
123
132
  localUpdatedAt.value = Timestamp.now()
@@ -203,8 +212,11 @@ async function toggleLike() {
203
212
  @paste="imageField?.handlePaste($event)"
204
213
  :rows="2"
205
214
  :maxlength="COMMENT_BODY_MAX_LENGTH"
215
+ :disabled="saving || aiBusy"
206
216
  autofocus
207
217
  />
218
+ <MemiBoardCommentAi v-model="editBody" v-model:translations="translations" :board-id="boardId"
219
+ :disabled="saving || imageBusy || isWriteRestricted" @busy="aiBusy = $event" />
208
220
  <MemiBoardCommentImageField
209
221
  ref="imageField"
210
222
  v-if="comment.id"
@@ -227,7 +239,7 @@ async function toggleLike() {
227
239
  size="xs"
228
240
  color="neutral"
229
241
  variant="ghost"
230
- :disabled="saving || imageBusy"
242
+ :disabled="saving || imageBusy || aiBusy"
231
243
  @click="cancelEdit"
232
244
  />
233
245
  <UButton
@@ -237,7 +249,7 @@ async function toggleLike() {
237
249
  :label="t('common.action.save')"
238
250
  size="xs"
239
251
  :loading="saving"
240
- :disabled="!editBody.trim() || imageBusy || (editBody.trim() === localBody && !imageChanged)"
252
+ :disabled="!editBody.trim() || imageBusy || aiBusy || (editBody.trim() === localBody && !imageChanged && !translations)"
241
253
  />
242
254
  </div>
243
255
  </form>
@@ -260,7 +272,7 @@ async function toggleLike() {
260
272
  <div v-else>
261
273
  <p class="whitespace-pre-wrap break-words text-sm">
262
274
  <span v-if="comment.parentId && comment.replyToName" class="mr-1 text-primary">@{{ comment.replyToName }}</span>
263
- {{ localBody }}
275
+ {{ displayedBody }}
264
276
  </p>
265
277
  <a
266
278
  v-if="localImage"
@@ -759,7 +759,6 @@ async function translateAllLocales() {
759
759
  aiRunning.value = true
760
760
  try {
761
761
  const translations = await translatePost({
762
- source: locale.value,
763
762
  targets: translationTargets.value,
764
763
  title: storedTitle,
765
764
  summary,
@@ -1061,7 +1060,6 @@ async function handleSubmit() {
1061
1060
  if (!props.postId && translationEnabled.value && getBoard(resolvedBoardId.value)?.autoTranslatePosts === true && !translations) {
1062
1061
  submitHint.value = t('editor.hint.translating', { count: translationTargets.value.length })
1063
1062
  translations = await translatePost({
1064
- source: locale.value,
1065
1063
  targets: translationTargets.value,
1066
1064
  title: storedTitle,
1067
1065
  summary: buildPostPreview(bodyContent, imageBoard ? [] : attachments.value).summary,
@@ -1,7 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, ref, resolveComponent } from 'vue'
3
3
  import type { BoardSection, CommentModel } from 'memi-board/runtime'
4
- import { miniCommentLink, useMemiBoardCommentFeed, useMemiBoardI18n } from 'memi-board/runtime'
4
+ import { displayCommentBody, miniCommentLink, useMemiBoardCommentFeed, useMemiBoardI18n } from 'memi-board/runtime'
5
5
 
6
6
  const props = defineProps<{
7
7
  section: BoardSection
@@ -9,7 +9,7 @@ const props = defineProps<{
9
9
  }>()
10
10
 
11
11
  const NuxtLink = resolveComponent('NuxtLink')
12
- const { t, formatRelativeDate } = useMemiBoardI18n()
12
+ const { t, locale, formatRelativeDate } = useMemiBoardI18n()
13
13
  const now = ref(Date.now())
14
14
  const sort = computed(() => props.section.sort === 'likes' ? 'likes' : 'latest')
15
15
  const { comments, commentsPending } = useMemiBoardCommentFeed(sort, {
@@ -56,7 +56,7 @@ function commentTo(comment: CommentModel) {
56
56
  v-if="comment.replyToName"
57
57
  class="mr-1 text-primary"
58
58
  >@{{ comment.replyToName }}</span>
59
- {{ comment.body }}
59
+ {{ displayCommentBody(comment, locale) }}
60
60
  </p>
61
61
  <p class="flex flex-wrap items-center gap-x-1.5 text-[11px] text-muted">
62
62
  <span>{{ comment.authorName || t('common.label.anonymous') }}</span>
@@ -1,6 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { ref, resolveComponent } from 'vue'
3
- import { useMemiBoardI18n, useMemiBoardUserComments } from 'memi-board/runtime'
3
+ import { displayCommentBody, useMemiBoardI18n, useMemiBoardUserComments } from 'memi-board/runtime'
4
4
  import type { CommentModel } from 'memi-board/runtime'
5
5
 
6
6
  const NuxtLink = resolveComponent('NuxtLink')
@@ -20,7 +20,7 @@ const props = withDefaults(defineProps<{
20
20
 
21
21
  const emit = defineEmits<{ select: [comment: CommentModel] }>()
22
22
 
23
- const { t, formatRelativeDate } = useMemiBoardI18n()
23
+ const { t, locale, formatRelativeDate } = useMemiBoardI18n()
24
24
 
25
25
  const { comments, commentsPending, hasMore, loadingMore, loadError, loadMore } = useMemiBoardUserComments(
26
26
  () => props.uid,
@@ -83,7 +83,7 @@ function commentLink(comment: CommentModel): string | undefined {
83
83
  v-else
84
84
  class="text-sm text-highlighted line-clamp-3 whitespace-pre-wrap"
85
85
  >
86
- {{ comment.body }}
86
+ {{ displayCommentBody(comment, locale) }}
87
87
  </p>
88
88
  <p class="mt-1 text-xs text-muted">
89
89
  {{ formatRelativeDate(comment.createdAt, now) }}
package/dist/storage.js CHANGED
@@ -1,4 +1,4 @@
1
- import { W as e, i as t, m as n, n as r, s as i, w as a } from "./commentImages-CzXO42gE.js";
1
+ import { W as e, i as t, m as n, n as r, s as i, w as a } from "./commentImages-D_JJnuoj.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";
package/dist/types.d.ts CHANGED
@@ -115,6 +115,13 @@ export interface PostDetail extends PostModel {
115
115
  * 보드 경계를 넘는 작성자별 글 목록(예: /board-user/{uid}) 등에서 PostModel 을 그대로 쓰면 된다.
116
116
  */
117
117
  export type UserPostModel = PostModel;
118
+ export interface CommentTranslationSet {
119
+ i18n: MemiBoardI18nMap;
120
+ i18nSource: {
121
+ locale?: MemiBoardLocale;
122
+ body: string;
123
+ };
124
+ }
118
125
  export interface CommentModel {
119
126
  id?: string;
120
127
  /** 속한 글. flat 컬렉션이라 경로 대신 필드로 스코핑한다. */
@@ -127,6 +134,8 @@ export interface CommentModel {
127
134
  */
128
135
  listed?: boolean;
129
136
  body: string;
137
+ i18n?: MemiBoardI18nMap;
138
+ i18nSource?: CommentTranslationSet['i18nSource'];
130
139
  authorUid: string;
131
140
  authorName: string | null;
132
141
  authorPhoto: string | null;
@@ -237,7 +246,7 @@ export interface BoardModel {
237
246
  * 없거나 false 이면 번역하지 않는다.
238
247
  */
239
248
  autoTranslate?: boolean;
240
- /** 새 글 작성 시 제목·요약·본문을 번역한다. 기본 false. */
249
+ /** 새 글과 댓글·답글 작성 시 번역한다. 기본 false. */
241
250
  autoTranslatePosts?: boolean;
242
251
  /**
243
252
  * 이름·설명과 글을 번역할 언어(MEMI_BOARD_LOCALES 코드).
@@ -1,5 +1,5 @@
1
1
  import { MemiBoardLocale } from '../i18n/locales';
2
- import { MemiBoardI18nMap, MemiBoardI18nSource, PostContentI18nSource, PostDetail, PostModel } from '../types';
2
+ import { CommentModel, MemiBoardI18nMap, MemiBoardI18nSource, PostContentI18nSource, PostDetail, PostModel } from '../types';
3
3
  /** 본문처럼 긴 문자열이 번역 당시와 같은지 비교할 때 쓴다. */
4
4
  export declare function hashText(value: string): string;
5
5
  /** Firestore에 넣은 i18n 맵만 남긴다. 지원하지 않는 키와 빈 문자열은 버린다. */
@@ -41,3 +41,5 @@ export declare function boardCopyIsTranslated(board: {
41
41
  i18nSource?: MemiBoardI18nSource;
42
42
  translationLocales?: readonly string[];
43
43
  }): boolean;
44
+ /** 수정 전의 번역은 숨기고 원문을 보여 준다. */
45
+ export declare function displayCommentBody(comment: Pick<CommentModel, 'body' | 'i18n' | 'i18nSource'>, locale?: string | null): string;