memi-board 0.47.0 → 0.48.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 +86 -18
- package/dist/{ar-D_6hknjh.js → ar-C-X7I5_n.js} +9 -2
- package/dist/ar-C-X7I5_n.js.map +1 -0
- package/dist/{slugify-DzXnx16m.js → commentImages-D1PEZIU9.js} +65 -16
- package/dist/commentImages-D1PEZIU9.js.map +1 -0
- package/dist/composables/useMemiBoardComments.d.ts +20 -2
- package/dist/composables/useMemiBoardStorage.d.ts +4 -0
- package/dist/{de-DQqSsBsZ.js → de-clWR1v1K.js} +9 -2
- package/dist/de-clWR1v1K.js.map +1 -0
- package/dist/{en-Broh-9df.js → en-BSBqcgqW.js} +11 -4
- package/dist/en-BSBqcgqW.js.map +1 -0
- package/dist/{es-D3eEYN6w.js → es-D65HxdZE.js} +9 -2
- package/dist/es-D65HxdZE.js.map +1 -0
- package/dist/{fr-CbXPRKeU.js → fr-Cn_Ys4zN.js} +9 -2
- package/dist/fr-Cn_Ys4zN.js.map +1 -0
- package/dist/{id-DxVqvPpo.js → id-zjyRo6dg.js} +9 -2
- package/dist/id-zjyRo6dg.js.map +1 -0
- package/dist/index.js +1090 -1016
- package/dist/index.js.map +1 -1
- package/dist/{ja-D2dIWUse.js → ja-BOTpLmpf.js} +9 -2
- package/dist/ja-BOTpLmpf.js.map +1 -0
- package/dist/{pt-BAuHdglU.js → pt-M4T-sRwC.js} +9 -2
- package/dist/pt-M4T-sRwC.js.map +1 -0
- package/dist/runtime/components/CommentForm.vue +40 -6
- package/dist/runtime/components/CommentImageField.vue +201 -0
- package/dist/runtime/components/CommentItem.vue +56 -10
- package/dist/storage.d.ts +2 -1
- package/dist/storage.js +101 -62
- package/dist/storage.js.map +1 -1
- package/dist/types.d.ts +8 -1
- package/dist/utils/commentImages.d.ts +7 -0
- package/dist/utils/deletePostCascade.d.ts +4 -1
- package/dist/{zh-CMyzTdQ5.js → zh-CtswuQ57.js} +9 -2
- package/dist/zh-CtswuQ57.js.map +1 -0
- package/docs/firebase-setup.md +8 -9
- package/docs/firestore.rules.example +70 -1
- package/docs/storage.rules.example +17 -2
- package/package.json +1 -1
- package/dist/ar-D_6hknjh.js.map +0 -1
- package/dist/de-DQqSsBsZ.js.map +0 -1
- package/dist/en-Broh-9df.js.map +0 -1
- package/dist/es-D3eEYN6w.js.map +0 -1
- package/dist/fr-CbXPRKeU.js.map +0 -1
- package/dist/id-DxVqvPpo.js.map +0 -1
- package/dist/ja-D2dIWUse.js.map +0 -1
- package/dist/pt-BAuHdglU.js.map +0 -1
- package/dist/slugify-DzXnx16m.js.map +0 -1
- package/dist/zh-CMyzTdQ5.js.map +0 -1
|
@@ -2,7 +2,8 @@
|
|
|
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 { CommentModel } from 'memi-board/runtime'
|
|
5
|
+
import type { Attachment, CommentModel } from 'memi-board/runtime'
|
|
6
|
+
import { useMemiBoardStorage } from 'memi-board/storage'
|
|
6
7
|
import {
|
|
7
8
|
COMMENT_BODY_MAX_LENGTH,
|
|
8
9
|
memiBoardCommentLikesKey,
|
|
@@ -11,6 +12,7 @@ import {
|
|
|
11
12
|
useMemiBoardI18n,
|
|
12
13
|
useMemiBoardModeration,
|
|
13
14
|
} from 'memi-board/runtime'
|
|
15
|
+
import MemiBoardCommentImageField from './CommentImageField.vue'
|
|
14
16
|
|
|
15
17
|
const props = defineProps<{
|
|
16
18
|
comment: CommentModel
|
|
@@ -32,12 +34,16 @@ const { updateComment, setCommentBlinded } = useMemiBoardComments(
|
|
|
32
34
|
{ subscribe: false },
|
|
33
35
|
)
|
|
34
36
|
const { checkText } = useMemiBoardModeration()
|
|
37
|
+
const { deleteAttachment } = useMemiBoardStorage()
|
|
35
38
|
const { t, formatRelativeDate, formatTimestampDetails } = useMemiBoardI18n()
|
|
36
39
|
const commentLikes = inject(memiBoardCommentLikesKey, null)
|
|
37
40
|
|
|
38
41
|
const editing = ref(false)
|
|
39
42
|
const editBody = ref(props.comment.body)
|
|
40
43
|
const localBody = ref(props.comment.body)
|
|
44
|
+
const localImage = ref<Attachment | null>(props.comment.images?.[0] ?? null)
|
|
45
|
+
const editImage = ref<Attachment | null>(localImage.value)
|
|
46
|
+
const imageBusy = ref(false)
|
|
41
47
|
const localUpdatedAt = ref(props.comment.updatedAt)
|
|
42
48
|
const localBlinded = ref(props.comment.isBlinded ?? false)
|
|
43
49
|
const saving = ref(false)
|
|
@@ -48,10 +54,17 @@ const localLikeCount = ref(props.comment.likeCount ?? 0)
|
|
|
48
54
|
const isLiked = computed(() => commentLikes?.isLiked(props.comment.id) ?? false)
|
|
49
55
|
const likePending = computed(() => commentLikes?.isPending(props.comment.id) ?? false)
|
|
50
56
|
|
|
57
|
+
const imageChanged = computed(() => (editImage.value?.path ?? '') !== (localImage.value?.path ?? ''))
|
|
58
|
+
|
|
51
59
|
watch(() => props.comment.body, (body) => {
|
|
52
60
|
localBody.value = body
|
|
53
61
|
if (!editing.value) editBody.value = body
|
|
54
62
|
})
|
|
63
|
+
watch(() => props.comment.images, (images) => {
|
|
64
|
+
if (editing.value) return
|
|
65
|
+
localImage.value = images?.[0] ?? null
|
|
66
|
+
editImage.value = localImage.value
|
|
67
|
+
})
|
|
55
68
|
watch(() => props.comment.updatedAt, (updatedAt) => { localUpdatedAt.value = updatedAt })
|
|
56
69
|
watch(() => props.comment.isBlinded, (isBlinded) => { localBlinded.value = isBlinded ?? false })
|
|
57
70
|
watch(() => props.comment.likeCount, (likeCount) => {
|
|
@@ -65,21 +78,27 @@ const blindLines = computed(() => Math.min(3, Math.max(1, Math.ceil(localBody.va
|
|
|
65
78
|
|
|
66
79
|
function startEdit() {
|
|
67
80
|
editBody.value = localBody.value
|
|
81
|
+
editImage.value = localImage.value
|
|
68
82
|
editError.value = ''
|
|
69
83
|
editing.value = true
|
|
70
84
|
}
|
|
71
85
|
|
|
72
|
-
function cancelEdit() {
|
|
86
|
+
async function cancelEdit() {
|
|
87
|
+
const draft = editImage.value
|
|
88
|
+
const original = localImage.value
|
|
73
89
|
editBody.value = localBody.value
|
|
90
|
+
editImage.value = original
|
|
74
91
|
editError.value = ''
|
|
75
92
|
editing.value = false
|
|
93
|
+
if (draft?.path && draft.path !== original?.path) await deleteAttachment(draft)
|
|
76
94
|
}
|
|
77
95
|
|
|
78
96
|
async function saveEdit() {
|
|
79
|
-
if (saving.value) return
|
|
97
|
+
if (saving.value || imageBusy.value) return
|
|
80
98
|
const body = editBody.value.trim()
|
|
81
|
-
if (!props.comment.id || !body
|
|
82
|
-
|
|
99
|
+
if (!props.comment.id || !body) return
|
|
100
|
+
if (body === localBody.value && !imageChanged.value) {
|
|
101
|
+
editing.value = false
|
|
83
102
|
return
|
|
84
103
|
}
|
|
85
104
|
if (isWriteRestricted.value) {
|
|
@@ -95,10 +114,14 @@ async function saveEdit() {
|
|
|
95
114
|
editError.value = moderation.reason || t('commentItem.moderationBlocked')
|
|
96
115
|
return
|
|
97
116
|
}
|
|
98
|
-
|
|
117
|
+
const previous = localImage.value
|
|
118
|
+
const next = editImage.value
|
|
119
|
+
await updateComment(props.comment.id, body, next ? [next] : [])
|
|
99
120
|
localBody.value = body
|
|
121
|
+
localImage.value = next
|
|
100
122
|
localUpdatedAt.value = Timestamp.now()
|
|
101
123
|
editing.value = false
|
|
124
|
+
if (previous?.path && previous.path !== next?.path) await deleteAttachment(previous)
|
|
102
125
|
}
|
|
103
126
|
catch (cause) {
|
|
104
127
|
editError.value = cause instanceof Error ? cause.message : t('commentItem.editFailed')
|
|
@@ -180,6 +203,14 @@ async function toggleLike() {
|
|
|
180
203
|
:maxlength="COMMENT_BODY_MAX_LENGTH"
|
|
181
204
|
autofocus
|
|
182
205
|
/>
|
|
206
|
+
<MemiBoardCommentImageField
|
|
207
|
+
v-if="comment.id"
|
|
208
|
+
v-model="editImage"
|
|
209
|
+
:post-id="postId"
|
|
210
|
+
:comment-id="comment.id"
|
|
211
|
+
:disabled="saving"
|
|
212
|
+
@busy="imageBusy = $event"
|
|
213
|
+
/>
|
|
183
214
|
<p class="text-right text-xs text-dimmed">
|
|
184
215
|
{{ editBody.length.toLocaleString() }} / {{ COMMENT_BODY_MAX_LENGTH.toLocaleString() }}
|
|
185
216
|
</p>
|
|
@@ -193,7 +224,7 @@ async function toggleLike() {
|
|
|
193
224
|
size="xs"
|
|
194
225
|
color="neutral"
|
|
195
226
|
variant="ghost"
|
|
196
|
-
:disabled="saving"
|
|
227
|
+
:disabled="saving || imageBusy"
|
|
197
228
|
@click="cancelEdit"
|
|
198
229
|
/>
|
|
199
230
|
<UButton
|
|
@@ -203,7 +234,7 @@ async function toggleLike() {
|
|
|
203
234
|
:label="t('common.action.save')"
|
|
204
235
|
size="xs"
|
|
205
236
|
:loading="saving"
|
|
206
|
-
:disabled="!editBody.trim() || editBody.trim() === localBody"
|
|
237
|
+
:disabled="!editBody.trim() || imageBusy || (editBody.trim() === localBody && !imageChanged)"
|
|
207
238
|
/>
|
|
208
239
|
</div>
|
|
209
240
|
</form>
|
|
@@ -223,10 +254,25 @@ async function toggleLike() {
|
|
|
223
254
|
{{ t('commentItem.blinded') }}
|
|
224
255
|
</div>
|
|
225
256
|
</div>
|
|
226
|
-
<
|
|
257
|
+
<div v-else>
|
|
258
|
+
<p class="whitespace-pre-wrap break-words text-sm">
|
|
227
259
|
<span v-if="comment.parentId && comment.replyToName" class="mr-1 text-primary">@{{ comment.replyToName }}</span>
|
|
228
260
|
{{ localBody }}
|
|
229
|
-
|
|
261
|
+
</p>
|
|
262
|
+
<a
|
|
263
|
+
v-if="localImage"
|
|
264
|
+
:href="localImage.url"
|
|
265
|
+
target="_blank"
|
|
266
|
+
rel="noopener noreferrer"
|
|
267
|
+
class="mt-2 inline-block"
|
|
268
|
+
>
|
|
269
|
+
<img
|
|
270
|
+
:src="localImage.url"
|
|
271
|
+
:alt="t('commentItem.attachedImage')"
|
|
272
|
+
class="max-h-60 max-w-full rounded-md border border-default object-contain"
|
|
273
|
+
>
|
|
274
|
+
</a>
|
|
275
|
+
</div>
|
|
230
276
|
<p v-if="likeError" class="mt-1 text-xs text-error">
|
|
231
277
|
{{ likeError }}
|
|
232
278
|
</p>
|
package/dist/storage.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 클라이언트 전용 서브패스 —
|
|
2
|
+
* 클라이언트 전용 서브패스 — 에디터·첨부·댓글 이미지 컴포넌트만 여기서 import 한다.
|
|
3
3
|
* canvas 기반 compressImage가 브라우저 전용이므로 SSR로 로드되는
|
|
4
4
|
* memi-board/runtime(dist/index.js)과 분리한다.
|
|
5
5
|
*/
|
|
6
6
|
export { useMemiBoardStorage, EDITOR_IMAGE_MAX_BYTES, EDITOR_IMAGE_SOURCE_MAX_BYTES } from './composables/useMemiBoardStorage';
|
|
7
|
+
export { isCommentImageFile } from './utils/commentImages';
|
|
7
8
|
export { compressImage } from './utils/compressImage';
|
package/dist/storage.js
CHANGED
|
@@ -1,135 +1,174 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { useFirebaseApp as
|
|
3
|
-
import { getAuth as
|
|
4
|
-
import { deleteObject as
|
|
1
|
+
import { W as e, i as t, m as n, n as r, s as i, w as a } from "./commentImages-D1PEZIU9.js";
|
|
2
|
+
import { useFirebaseApp as o } from "vuefire";
|
|
3
|
+
import { getAuth as s } from "firebase/auth";
|
|
4
|
+
import { deleteObject as c, getDownloadURL as l, getStorage as u, ref as d, uploadBytes as f, uploadBytesResumable as p } from "firebase/storage";
|
|
5
5
|
//#region src/utils/compressImage.ts
|
|
6
|
-
async function
|
|
6
|
+
async function m(e, { maxWidth: t = 400, quality: r = .8 } = {}, i) {
|
|
7
7
|
return new Promise((a, o) => {
|
|
8
8
|
let s = new Image(), c = URL.createObjectURL(e);
|
|
9
9
|
s.onload = () => {
|
|
10
10
|
URL.revokeObjectURL(c);
|
|
11
|
-
let e = Math.min(1,
|
|
11
|
+
let e = Math.min(1, t / s.naturalWidth), l = document.createElement("canvas");
|
|
12
12
|
l.width = Math.round(s.naturalWidth * e), l.height = Math.round(s.naturalHeight * e);
|
|
13
13
|
let u = l.getContext("2d");
|
|
14
14
|
if (!u) {
|
|
15
|
-
o(Error(
|
|
15
|
+
o(Error(n(i, "storage.canvasUnavailable")));
|
|
16
16
|
return;
|
|
17
17
|
}
|
|
18
|
-
u.drawImage(s, 0, 0, l.width, l.height), l.toBlob((e) => e ? a(e) : o(Error(
|
|
18
|
+
u.drawImage(s, 0, 0, l.width, l.height), l.toBlob((e) => e ? a(e) : o(Error(n(i, "storage.compressFailed"))), "image/jpeg", r);
|
|
19
19
|
}, s.onerror = () => {
|
|
20
|
-
URL.revokeObjectURL(c), o(Error(
|
|
20
|
+
URL.revokeObjectURL(c), o(Error(n(i, "storage.loadFailed")));
|
|
21
21
|
}, s.src = c;
|
|
22
22
|
});
|
|
23
23
|
}
|
|
24
24
|
//#endregion
|
|
25
25
|
//#region src/composables/useMemiBoardStorage.ts
|
|
26
|
-
var
|
|
27
|
-
function
|
|
26
|
+
var h = 5242880, g = 26214400;
|
|
27
|
+
function _(e) {
|
|
28
28
|
let t = (e.type || "").toLowerCase(), n = e.name.toLowerCase();
|
|
29
29
|
return t === "image/heic" || t === "image/heif" || n.endsWith(".heic") || n.endsWith(".heif");
|
|
30
30
|
}
|
|
31
|
-
var
|
|
32
|
-
async function
|
|
31
|
+
var v = class extends Error {};
|
|
32
|
+
async function y(e, t) {
|
|
33
33
|
if (e.size <= 5242880) return e;
|
|
34
|
-
let r = await
|
|
34
|
+
let r = await m(e, {
|
|
35
35
|
maxWidth: 2560,
|
|
36
36
|
quality: .85
|
|
37
|
-
},
|
|
38
|
-
if (r.size > 5242880 && (r = await
|
|
37
|
+
}, t);
|
|
38
|
+
if (r.size > 5242880 && (r = await m(e, {
|
|
39
39
|
maxWidth: 2048,
|
|
40
40
|
quality: .75
|
|
41
|
-
},
|
|
41
|
+
}, t)), r.size > 5242880) throw new v(n(t, "storage.tooLargeAfterCompress"));
|
|
42
42
|
return r;
|
|
43
43
|
}
|
|
44
|
-
async function
|
|
45
|
-
if (e.size > 26214400) throw Error(t
|
|
46
|
-
if (
|
|
44
|
+
async function b(e, t) {
|
|
45
|
+
if (e.size > 26214400) throw Error(n(t, "storage.sourceTooLarge", { max: g / 1024 / 1024 }));
|
|
46
|
+
if (_(e)) throw Error(n(t, "storage.heicUnsupported"));
|
|
47
47
|
if (e.size <= 5242880) return {
|
|
48
48
|
blob: e,
|
|
49
49
|
compressed: !1
|
|
50
50
|
};
|
|
51
51
|
try {
|
|
52
52
|
return {
|
|
53
|
-
blob: await
|
|
53
|
+
blob: await y(e, t),
|
|
54
54
|
compressed: !0
|
|
55
55
|
};
|
|
56
56
|
} catch (e) {
|
|
57
|
-
throw e instanceof
|
|
57
|
+
throw e instanceof v ? e : Error(n(t, "storage.cannotOptimize"));
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
|
-
function
|
|
61
|
-
let
|
|
62
|
-
function
|
|
63
|
-
let e =
|
|
60
|
+
function x() {
|
|
61
|
+
let n = () => a(), h = o(), { t: g, locale: _ } = i();
|
|
62
|
+
function v() {
|
|
63
|
+
let e = s(h).currentUser?.uid;
|
|
64
64
|
if (!e) throw Error("Sign in before uploading a file.");
|
|
65
65
|
return { ownerUid: e };
|
|
66
66
|
}
|
|
67
|
-
function
|
|
68
|
-
let o =
|
|
69
|
-
customMetadata:
|
|
70
|
-
contentType:
|
|
71
|
-
contentDisposition: `attachment; filename*=UTF-8''${encodeURIComponent(
|
|
67
|
+
function y(r, i, a) {
|
|
68
|
+
let o = u(h), s = `${e(n(), i)}/attachments/${Date.now()}-${t(r.name)}`, c = d(o, s), f = p(c, r, {
|
|
69
|
+
customMetadata: v(),
|
|
70
|
+
contentType: r.type || "application/octet-stream",
|
|
71
|
+
contentDisposition: `attachment; filename*=UTF-8''${encodeURIComponent(r.name)}`
|
|
72
72
|
});
|
|
73
73
|
return {
|
|
74
74
|
promise: new Promise((e, t) => {
|
|
75
|
-
|
|
75
|
+
f.on("state_changed", (e) => {
|
|
76
76
|
a && e.totalBytes > 0 && a(e.bytesTransferred / e.totalBytes);
|
|
77
77
|
}, t, async () => {
|
|
78
|
-
let t = await c
|
|
78
|
+
let t = await l(c);
|
|
79
79
|
e({
|
|
80
|
-
name:
|
|
80
|
+
name: r.name,
|
|
81
81
|
url: t,
|
|
82
82
|
path: s,
|
|
83
|
-
size:
|
|
84
|
-
type:
|
|
83
|
+
size: r.size,
|
|
84
|
+
type: r.type
|
|
85
85
|
});
|
|
86
86
|
});
|
|
87
87
|
}),
|
|
88
|
-
cancel: () =>
|
|
88
|
+
cancel: () => f.cancel()
|
|
89
89
|
};
|
|
90
90
|
}
|
|
91
|
-
async function
|
|
92
|
-
let t =
|
|
93
|
-
await
|
|
91
|
+
async function x(e) {
|
|
92
|
+
let t = u(h);
|
|
93
|
+
await c(d(t, e.path)).catch(() => {});
|
|
94
94
|
}
|
|
95
|
-
|
|
96
|
-
if (!
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
95
|
+
function S(i, a, o, s) {
|
|
96
|
+
if (!r(i)) throw Error(g("commentForm.imageOnly"));
|
|
97
|
+
if (!/^[A-Za-z0-9]{10,128}$/.test(a) || !/^[A-Za-z0-9]{10,128}$/.test(o)) throw Error(g("commentForm.uploadFailed"));
|
|
98
|
+
let c = v(), f = u(h), y, x = !1;
|
|
99
|
+
return {
|
|
100
|
+
promise: (async () => {
|
|
101
|
+
let r = await b(i, _.value);
|
|
102
|
+
if (x) throw Error(g("commentForm.uploadFailed"));
|
|
103
|
+
let u = r.blob;
|
|
104
|
+
if (u.size >= 5242880 && (u = await m(u, {
|
|
105
|
+
maxWidth: 2048,
|
|
106
|
+
quality: .75
|
|
107
|
+
}, _.value)), x) throw Error(g("commentForm.uploadFailed"));
|
|
108
|
+
if (u.size >= 5242880) throw Error(g("storage.tooLargeAfterCompress"));
|
|
109
|
+
let h = (u.type || i.type || "image/jpeg").toLowerCase(), v = h === "image/png" ? "png" : h === "image/gif" ? "gif" : h === "image/webp" ? "webp" : "jpg", S = t(i.name.replace(/\.[^.]+$/, "") || "image", 40) || "image", C = `${e(n(), a)}/comment-images/${o}/${Date.now()}-${S}.${v}`, w = d(f, C);
|
|
110
|
+
if (y = p(w, u, {
|
|
111
|
+
customMetadata: c,
|
|
112
|
+
contentType: h
|
|
113
|
+
}), x) throw y.cancel(), Error(g("commentForm.uploadFailed"));
|
|
114
|
+
await new Promise((e, t) => {
|
|
115
|
+
y.on("state_changed", (e) => {
|
|
116
|
+
s && e.totalBytes > 0 && s(e.bytesTransferred / e.totalBytes);
|
|
117
|
+
}, t, () => e());
|
|
118
|
+
});
|
|
119
|
+
let T = await l(w);
|
|
120
|
+
return {
|
|
121
|
+
name: i.name || `image.${v}`,
|
|
122
|
+
url: T,
|
|
123
|
+
path: C,
|
|
124
|
+
size: u.size,
|
|
125
|
+
type: h
|
|
126
|
+
};
|
|
127
|
+
})(),
|
|
128
|
+
cancel: () => {
|
|
129
|
+
x = !0, y?.cancel();
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
async function C(r, i) {
|
|
134
|
+
if (!r.type.startsWith("image/")) throw Error(g("storage.imageOnly"));
|
|
135
|
+
let a = await b(r, _.value), o = u(h), s = (a.compressed ? "jpg" : r.name.split(".").pop() || r.type.split("/")[1] || "png").replace(/[^a-zA-Z0-9]/g, "").slice(0, 8) || "png", c = t(r.name.replace(/\.[^.]+$/, "") || "image", 40), p = `${Date.now()}-${c || "image"}`, y = i || `new-${Date.now()}`, x = e(n(), y), S = `${x}/images/${p}.${s}`, C = d(o, S);
|
|
136
|
+
await f(C, a.blob, {
|
|
137
|
+
customMetadata: v(),
|
|
100
138
|
contentType: a.blob.type || "image/jpeg"
|
|
101
139
|
});
|
|
102
|
-
let w = await
|
|
140
|
+
let w = await l(C), T = await m(a.blob, {
|
|
103
141
|
maxWidth: 400,
|
|
104
142
|
quality: .8
|
|
105
|
-
},
|
|
106
|
-
return await
|
|
143
|
+
}, _.value), E = `${x}/images/thumbnails/${p}.jpg`, D = d(o, E);
|
|
144
|
+
return await f(D, T, {
|
|
107
145
|
contentType: "image/jpeg",
|
|
108
|
-
customMetadata:
|
|
146
|
+
customMetadata: v()
|
|
109
147
|
}), {
|
|
110
148
|
originalUrl: w,
|
|
111
149
|
originalPath: S,
|
|
112
|
-
thumbnailUrl: await
|
|
150
|
+
thumbnailUrl: await l(D),
|
|
113
151
|
thumbnailPath: E
|
|
114
152
|
};
|
|
115
153
|
}
|
|
116
|
-
async function
|
|
117
|
-
let t =
|
|
118
|
-
await Promise.all([
|
|
154
|
+
async function w(e) {
|
|
155
|
+
let t = u(h);
|
|
156
|
+
await Promise.all([c(d(t, e.originalPath)).catch(() => {}), c(d(t, e.thumbnailPath)).catch(() => {})]);
|
|
119
157
|
}
|
|
120
|
-
async function
|
|
121
|
-
let r =
|
|
122
|
-
return await
|
|
158
|
+
async function T(e, t) {
|
|
159
|
+
let r = u(h), i = `${n().usersCollection}/${e}/avatar.jpg`, a = d(r, i);
|
|
160
|
+
return await f(a, t, { contentType: t.type || "image/jpeg" }), l(a);
|
|
123
161
|
}
|
|
124
162
|
return {
|
|
125
|
-
uploadAttachment:
|
|
126
|
-
deleteAttachment:
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
163
|
+
uploadAttachment: y,
|
|
164
|
+
deleteAttachment: x,
|
|
165
|
+
uploadCommentImage: S,
|
|
166
|
+
uploadEditorImage: C,
|
|
167
|
+
deleteEditorImage: w,
|
|
168
|
+
uploadAvatar: T
|
|
130
169
|
};
|
|
131
170
|
}
|
|
132
171
|
//#endregion
|
|
133
|
-
export {
|
|
172
|
+
export { h as EDITOR_IMAGE_MAX_BYTES, g as EDITOR_IMAGE_SOURCE_MAX_BYTES, m as compressImage, r as isCommentImageFile, x as useMemiBoardStorage };
|
|
134
173
|
|
|
135
174
|
//# sourceMappingURL=storage.js.map
|
package/dist/storage.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"storage.js","names":[],"sources":["../src/utils/compressImage.ts","../src/composables/useMemiBoardStorage.ts"],"sourcesContent":["import type { MemiBoardLocale } from '../i18n/locales'\nimport { translate } from '../i18n/translate'\n\n/**\n * Canvas API로 썸네일용 이미지 압축 (shineb 와 동일).\n */\nexport async function compressImage(\n file: File | Blob,\n { maxWidth = 400, quality = 0.8 }: { maxWidth?: number, quality?: number } = {},\n locale?: MemiBoardLocale,\n): Promise<Blob> {\n return new Promise((resolve, reject) => {\n const img = new Image()\n const objectUrl = URL.createObjectURL(file)\n\n img.onload = () => {\n URL.revokeObjectURL(objectUrl)\n\n const ratio = Math.min(1, maxWidth / img.naturalWidth)\n const canvas = document.createElement('canvas')\n canvas.width = Math.round(img.naturalWidth * ratio)\n canvas.height = Math.round(img.naturalHeight * ratio)\n\n const ctx = canvas.getContext('2d')\n if (!ctx) {\n reject(new Error(translate(locale, 'storage.canvasUnavailable')))\n return\n }\n ctx.drawImage(img, 0, 0, canvas.width, canvas.height)\n\n canvas.toBlob(\n blob => (blob ? resolve(blob) : reject(new Error(translate(locale, 'storage.compressFailed')))),\n 'image/jpeg',\n quality,\n )\n }\n\n img.onerror = () => {\n URL.revokeObjectURL(objectUrl)\n reject(new Error(translate(locale, 'storage.loadFailed')))\n }\n\n img.src = objectUrl\n })\n}\n","import {\n getStorage,\n ref as storageRef,\n uploadBytes,\n uploadBytesResumable,\n getDownloadURL,\n deleteObject,\n} from 'firebase/storage'\nimport type { UploadTask } from 'firebase/storage'\nimport { useFirebaseApp } from 'vuefire'\nimport { getAuth } from 'firebase/auth'\nimport { safeFileName } from '../utils/slugify'\nimport { compressImage } from '../utils/compressImage'\nimport { useBoardPathConfig } from '../config'\nimport { postStorageFolder } from '../utils/boardPaths'\nimport type { Attachment, EditorImageEntry } from '../types'\nimport type { MemiBoardLocale } from '../i18n/locales'\nimport { translate } from '../i18n/translate'\nimport { useMemiBoardI18n } from '../i18n/useMemiBoardI18n'\n\n/** 에디터 이미지 최대 크기 (바이트) */\nexport const EDITOR_IMAGE_MAX_BYTES = 5 * 1024 * 1024\n/** 휴대폰 원본 이미지 선택 허용 크기. 큰 이미지는 업로드 전에 최적화한다. */\nexport const EDITOR_IMAGE_SOURCE_MAX_BYTES = 25 * 1024 * 1024\n\nfunction isHeicLike(file: File): boolean {\n const type = (file.type || '').toLowerCase()\n const name = file.name.toLowerCase()\n return type === 'image/heic'\n || type === 'image/heif'\n || name.endsWith('.heic')\n || name.endsWith('.heif')\n}\n\n/** 압축해도 5MB 를 넘는 경우 — 다른 최적화 실패와 구분해 그대로 보여 준다. */\nclass ImageTooLargeError extends Error {}\n\nasync function resizeJpegIfNeeded(blob: Blob, locale?: MemiBoardLocale): Promise<Blob> {\n if (blob.size <= EDITOR_IMAGE_MAX_BYTES) return blob\n let next = await compressImage(blob, { maxWidth: 2560, quality: 0.85 }, locale)\n if (next.size > EDITOR_IMAGE_MAX_BYTES) {\n next = await compressImage(blob, { maxWidth: 2048, quality: 0.75 }, locale)\n }\n if (next.size > EDITOR_IMAGE_MAX_BYTES) {\n throw new ImageTooLargeError(translate(locale, 'storage.tooLargeAfterCompress'))\n }\n return next\n}\n\n/**\n * 에디터 업로드용 정규화.\n * - 5MB 초과 → 리사이즈\n * - HEIC/HEIF 원본 파일 → 호환 형식 안내\n */\nasync function optimizeEditorImage(file: File, locale?: MemiBoardLocale): Promise<{ blob: File | Blob, compressed: boolean }> {\n if (file.size > EDITOR_IMAGE_SOURCE_MAX_BYTES) {\n throw new Error(translate(locale, 'storage.sourceTooLarge', { max: EDITOR_IMAGE_SOURCE_MAX_BYTES / 1024 / 1024 }))\n }\n\n if (isHeicLike(file)) {\n throw new Error(translate(locale, 'storage.heicUnsupported'))\n }\n\n if (file.size <= EDITOR_IMAGE_MAX_BYTES) {\n return { blob: file, compressed: false }\n }\n\n try {\n const blob = await resizeJpegIfNeeded(file, locale)\n return { blob, compressed: true }\n }\n catch (cause) {\n if (cause instanceof ImageTooLargeError) throw cause\n throw new Error(translate(locale, 'storage.cannotOptimize'))\n }\n}\n\nexport function useMemiBoardStorage() {\n const cfg = () => useBoardPathConfig()\n const app = useFirebaseApp()\n const { t, locale } = useMemiBoardI18n()\n\n function uploadMetadata() {\n const uid = getAuth(app).currentUser?.uid\n if (!uid) throw new Error('Sign in before uploading a file.')\n return { ownerUid: uid }\n }\n\n /** postId는 작성 화면 진입 시 미리 생성한 Firestore 자동 ID를 사용한다. */\n function uploadAttachment(\n file: File,\n postId: string,\n onProgress?: (ratio: number) => void,\n ): { promise: Promise<Attachment>, cancel: () => void } {\n const storage = getStorage(app)\n const path = `${postStorageFolder(cfg(), postId)}/attachments/${Date.now()}-${safeFileName(file.name)}`\n const fileRef = storageRef(storage, path)\n const task: UploadTask = uploadBytesResumable(fileRef, file, {\n customMetadata: uploadMetadata(),\n contentType: file.type || 'application/octet-stream',\n contentDisposition: `attachment; filename*=UTF-8''${encodeURIComponent(file.name)}`,\n })\n\n const promise = new Promise<Attachment>((resolve, reject) => {\n task.on(\n 'state_changed',\n (snapshot) => {\n if (onProgress && snapshot.totalBytes > 0) {\n onProgress(snapshot.bytesTransferred / snapshot.totalBytes)\n }\n },\n reject,\n async () => {\n const url = await getDownloadURL(fileRef)\n resolve({ name: file.name, url, path, size: file.size, type: file.type })\n },\n )\n })\n\n return { promise, cancel: () => task.cancel() }\n }\n\n async function deleteAttachment(attachment: Pick<Attachment, 'path'>): Promise<void> {\n const storage = getStorage(app)\n await deleteObject(storageRef(storage, attachment.path)).catch(() => {})\n }\n\n /**\n * 에디터 본문 이미지: 원본 + 400px JPEG 썸네일.\n * Storage:\n * `memiBoardPosts/{postId}/images/{ts}-{name}.ext`\n * `memiBoardPosts/{postId}/images/thumbnails/{ts}-{name}.jpg`\n */\n async function uploadEditorImage(file: File, postId: string): Promise<EditorImageEntry> {\n if (!file.type.startsWith('image/')) {\n throw new Error(t('storage.imageOnly'))\n }\n const optimized = await optimizeEditorImage(file, locale.value)\n\n const storage = getStorage(app)\n const ext = (optimized.compressed ? 'jpg' : (file.name.split('.').pop() || file.type.split('/')[1] || 'png'))\n .replace(/[^a-zA-Z0-9]/g, '')\n .slice(0, 8) || 'png'\n const safeName = safeFileName(file.name.replace(/\\.[^.]+$/, '') || 'image', 40)\n const baseName = `${Date.now()}-${safeName || 'image'}`\n const ns = postId || `new-${Date.now()}`\n const folder = postStorageFolder(cfg(), ns)\n\n const originalPath = `${folder}/images/${baseName}.${ext}`\n const originalRef = storageRef(storage, originalPath)\n await uploadBytes(originalRef, optimized.blob, {\n customMetadata: uploadMetadata(),\n contentType: optimized.blob.type || 'image/jpeg',\n })\n const originalUrl = await getDownloadURL(originalRef)\n\n const thumbBlob = await compressImage(optimized.blob, { maxWidth: 400, quality: 0.8 }, locale.value)\n const thumbnailPath = `${folder}/images/thumbnails/${baseName}.jpg`\n const thumbRef = storageRef(storage, thumbnailPath)\n await uploadBytes(thumbRef, thumbBlob, { contentType: 'image/jpeg', customMetadata: uploadMetadata() })\n const thumbnailUrl = await getDownloadURL(thumbRef)\n\n return { originalUrl, originalPath, thumbnailUrl, thumbnailPath }\n }\n\n /** 원본·썸네일 쌍 삭제 (고아 정리·첨부 삭제용) */\n async function deleteEditorImage(entry: Pick<EditorImageEntry, 'originalPath' | 'thumbnailPath'>): Promise<void> {\n const storage = getStorage(app)\n await Promise.all([\n deleteObject(storageRef(storage, entry.originalPath)).catch(() => {}),\n deleteObject(storageRef(storage, entry.thumbnailPath)).catch(() => {}),\n ])\n }\n\n /**\n * 프로필 사진. 경로가 uid로 고정돼 있어 다시 올리면 그냥 덮어쓴다(고아 파일 없음).\n * 파일은 이미 크롭 단계에서 정사각형 JPEG로 만들어져 오므로 여기선 그대로 업로드한다.\n */\n async function uploadAvatar(uid: string, file: File): Promise<string> {\n const storage = getStorage(app)\n const path = `${cfg().usersCollection}/${uid}/avatar.jpg`\n const fileRef = storageRef(storage, path)\n await uploadBytes(fileRef, file, { contentType: file.type || 'image/jpeg' })\n return getDownloadURL(fileRef)\n }\n\n return {\n uploadAttachment,\n deleteAttachment,\n uploadEditorImage,\n deleteEditorImage,\n uploadAvatar,\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"storage.js","names":[],"sources":["../src/utils/compressImage.ts","../src/composables/useMemiBoardStorage.ts"],"sourcesContent":["import type { MemiBoardLocale } from '../i18n/locales'\nimport { translate } from '../i18n/translate'\n\n/**\n * Canvas API로 썸네일용 이미지 압축 (shineb 와 동일).\n */\nexport async function compressImage(\n file: File | Blob,\n { maxWidth = 400, quality = 0.8 }: { maxWidth?: number, quality?: number } = {},\n locale?: MemiBoardLocale,\n): Promise<Blob> {\n return new Promise((resolve, reject) => {\n const img = new Image()\n const objectUrl = URL.createObjectURL(file)\n\n img.onload = () => {\n URL.revokeObjectURL(objectUrl)\n\n const ratio = Math.min(1, maxWidth / img.naturalWidth)\n const canvas = document.createElement('canvas')\n canvas.width = Math.round(img.naturalWidth * ratio)\n canvas.height = Math.round(img.naturalHeight * ratio)\n\n const ctx = canvas.getContext('2d')\n if (!ctx) {\n reject(new Error(translate(locale, 'storage.canvasUnavailable')))\n return\n }\n ctx.drawImage(img, 0, 0, canvas.width, canvas.height)\n\n canvas.toBlob(\n blob => (blob ? resolve(blob) : reject(new Error(translate(locale, 'storage.compressFailed')))),\n 'image/jpeg',\n quality,\n )\n }\n\n img.onerror = () => {\n URL.revokeObjectURL(objectUrl)\n reject(new Error(translate(locale, 'storage.loadFailed')))\n }\n\n img.src = objectUrl\n })\n}\n","import {\n getStorage,\n ref as storageRef,\n uploadBytes,\n uploadBytesResumable,\n getDownloadURL,\n deleteObject,\n} from 'firebase/storage'\nimport type { UploadTask } from 'firebase/storage'\nimport { useFirebaseApp } from 'vuefire'\nimport { getAuth } from 'firebase/auth'\nimport { isCommentImageFile } from '../utils/commentImages'\nimport { safeFileName } from '../utils/slugify'\nimport { compressImage } from '../utils/compressImage'\nimport { useBoardPathConfig } from '../config'\nimport { postStorageFolder } from '../utils/boardPaths'\nimport type { Attachment, EditorImageEntry } from '../types'\nimport type { MemiBoardLocale } from '../i18n/locales'\nimport { translate } from '../i18n/translate'\nimport { useMemiBoardI18n } from '../i18n/useMemiBoardI18n'\n\n/** 에디터 이미지 최대 크기 (바이트) */\nexport const EDITOR_IMAGE_MAX_BYTES = 5 * 1024 * 1024\n/** 휴대폰 원본 이미지 선택 허용 크기. 큰 이미지는 업로드 전에 최적화한다. */\nexport const EDITOR_IMAGE_SOURCE_MAX_BYTES = 25 * 1024 * 1024\n\nfunction isHeicLike(file: File): boolean {\n const type = (file.type || '').toLowerCase()\n const name = file.name.toLowerCase()\n return type === 'image/heic'\n || type === 'image/heif'\n || name.endsWith('.heic')\n || name.endsWith('.heif')\n}\n\n/** 압축해도 5MB 를 넘는 경우 — 다른 최적화 실패와 구분해 그대로 보여 준다. */\nclass ImageTooLargeError extends Error {}\n\nasync function resizeJpegIfNeeded(blob: Blob, locale?: MemiBoardLocale): Promise<Blob> {\n if (blob.size <= EDITOR_IMAGE_MAX_BYTES) return blob\n let next = await compressImage(blob, { maxWidth: 2560, quality: 0.85 }, locale)\n if (next.size > EDITOR_IMAGE_MAX_BYTES) {\n next = await compressImage(blob, { maxWidth: 2048, quality: 0.75 }, locale)\n }\n if (next.size > EDITOR_IMAGE_MAX_BYTES) {\n throw new ImageTooLargeError(translate(locale, 'storage.tooLargeAfterCompress'))\n }\n return next\n}\n\n/**\n * 에디터 업로드용 정규화.\n * - 5MB 초과 → 리사이즈\n * - HEIC/HEIF 원본 파일 → 호환 형식 안내\n */\nasync function optimizeEditorImage(file: File, locale?: MemiBoardLocale): Promise<{ blob: File | Blob, compressed: boolean }> {\n if (file.size > EDITOR_IMAGE_SOURCE_MAX_BYTES) {\n throw new Error(translate(locale, 'storage.sourceTooLarge', { max: EDITOR_IMAGE_SOURCE_MAX_BYTES / 1024 / 1024 }))\n }\n\n if (isHeicLike(file)) {\n throw new Error(translate(locale, 'storage.heicUnsupported'))\n }\n\n if (file.size <= EDITOR_IMAGE_MAX_BYTES) {\n return { blob: file, compressed: false }\n }\n\n try {\n const blob = await resizeJpegIfNeeded(file, locale)\n return { blob, compressed: true }\n }\n catch (cause) {\n if (cause instanceof ImageTooLargeError) throw cause\n throw new Error(translate(locale, 'storage.cannotOptimize'))\n }\n}\n\nexport function useMemiBoardStorage() {\n const cfg = () => useBoardPathConfig()\n const app = useFirebaseApp()\n const { t, locale } = useMemiBoardI18n()\n\n function uploadMetadata() {\n const uid = getAuth(app).currentUser?.uid\n if (!uid) throw new Error('Sign in before uploading a file.')\n return { ownerUid: uid }\n }\n\n /** postId는 작성 화면 진입 시 미리 생성한 Firestore 자동 ID를 사용한다. */\n function uploadAttachment(\n file: File,\n postId: string,\n onProgress?: (ratio: number) => void,\n ): { promise: Promise<Attachment>, cancel: () => void } {\n const storage = getStorage(app)\n const path = `${postStorageFolder(cfg(), postId)}/attachments/${Date.now()}-${safeFileName(file.name)}`\n const fileRef = storageRef(storage, path)\n const task: UploadTask = uploadBytesResumable(fileRef, file, {\n customMetadata: uploadMetadata(),\n contentType: file.type || 'application/octet-stream',\n contentDisposition: `attachment; filename*=UTF-8''${encodeURIComponent(file.name)}`,\n })\n\n const promise = new Promise<Attachment>((resolve, reject) => {\n task.on(\n 'state_changed',\n (snapshot) => {\n if (onProgress && snapshot.totalBytes > 0) {\n onProgress(snapshot.bytesTransferred / snapshot.totalBytes)\n }\n },\n reject,\n async () => {\n const url = await getDownloadURL(fileRef)\n resolve({ name: file.name, url, path, size: file.size, type: file.type })\n },\n )\n })\n\n return { promise, cancel: () => task.cancel() }\n }\n\n async function deleteAttachment(attachment: Pick<Attachment, 'path'>): Promise<void> {\n const storage = getStorage(app)\n await deleteObject(storageRef(storage, attachment.path)).catch(() => {})\n }\n\n /**\n * 댓글 이미지 1장.\n * `memiBoardPosts/{postId}/comment-images/{commentId}/{ts}-{name}.ext`\n * 댓글 문서보다 먼저 올릴 수 있다. 저장 상한은 본문 이미지와 같은 5MB.\n */\n function uploadCommentImage(\n file: File,\n postId: string,\n commentId: string,\n onProgress?: (ratio: number) => void,\n ): { promise: Promise<Attachment>, cancel: () => void } {\n if (!isCommentImageFile(file)) throw new Error(t('commentForm.imageOnly'))\n if (!/^[A-Za-z0-9]{10,128}$/.test(postId) || !/^[A-Za-z0-9]{10,128}$/.test(commentId)) {\n throw new Error(t('commentForm.uploadFailed'))\n }\n\n const metadata = uploadMetadata()\n const storage = getStorage(app)\n let task: UploadTask | undefined\n let cancelled = false\n const promise = (async () => {\n const optimized = await optimizeEditorImage(file, locale.value)\n if (cancelled) throw new Error(t('commentForm.uploadFailed'))\n let blob = optimized.blob\n if (blob.size >= EDITOR_IMAGE_MAX_BYTES) {\n blob = await compressImage(blob, { maxWidth: 2048, quality: 0.75 }, locale.value)\n }\n if (cancelled) throw new Error(t('commentForm.uploadFailed'))\n if (blob.size >= EDITOR_IMAGE_MAX_BYTES) {\n throw new Error(t('storage.tooLargeAfterCompress'))\n }\n const type = (blob.type || file.type || 'image/jpeg').toLowerCase()\n const ext = type === 'image/png'\n ? 'png'\n : type === 'image/gif'\n ? 'gif'\n : type === 'image/webp'\n ? 'webp'\n : 'jpg'\n const stem = safeFileName(file.name.replace(/\\.[^.]+$/, '') || 'image', 40) || 'image'\n const path = `${postStorageFolder(cfg(), postId)}/comment-images/${commentId}/${Date.now()}-${stem}.${ext}`\n const fileRef = storageRef(storage, path)\n task = uploadBytesResumable(fileRef, blob, {\n customMetadata: metadata,\n contentType: type,\n })\n if (cancelled) {\n task.cancel()\n throw new Error(t('commentForm.uploadFailed'))\n }\n await new Promise<void>((resolve, reject) => {\n task!.on(\n 'state_changed',\n (snapshot) => {\n if (onProgress && snapshot.totalBytes > 0) {\n onProgress(snapshot.bytesTransferred / snapshot.totalBytes)\n }\n },\n reject,\n () => resolve(),\n )\n })\n const url = await getDownloadURL(fileRef)\n return {\n name: file.name || `image.${ext}`,\n url,\n path,\n size: blob.size,\n type,\n }\n })()\n\n return {\n promise,\n cancel: () => {\n cancelled = true\n task?.cancel()\n },\n }\n }\n\n /**\n * 에디터 본문 이미지: 원본 + 400px JPEG 썸네일.\n * Storage:\n * `memiBoardPosts/{postId}/images/{ts}-{name}.ext`\n * `memiBoardPosts/{postId}/images/thumbnails/{ts}-{name}.jpg`\n */\n async function uploadEditorImage(file: File, postId: string): Promise<EditorImageEntry> {\n if (!file.type.startsWith('image/')) {\n throw new Error(t('storage.imageOnly'))\n }\n const optimized = await optimizeEditorImage(file, locale.value)\n\n const storage = getStorage(app)\n const ext = (optimized.compressed ? 'jpg' : (file.name.split('.').pop() || file.type.split('/')[1] || 'png'))\n .replace(/[^a-zA-Z0-9]/g, '')\n .slice(0, 8) || 'png'\n const safeName = safeFileName(file.name.replace(/\\.[^.]+$/, '') || 'image', 40)\n const baseName = `${Date.now()}-${safeName || 'image'}`\n const ns = postId || `new-${Date.now()}`\n const folder = postStorageFolder(cfg(), ns)\n\n const originalPath = `${folder}/images/${baseName}.${ext}`\n const originalRef = storageRef(storage, originalPath)\n await uploadBytes(originalRef, optimized.blob, {\n customMetadata: uploadMetadata(),\n contentType: optimized.blob.type || 'image/jpeg',\n })\n const originalUrl = await getDownloadURL(originalRef)\n\n const thumbBlob = await compressImage(optimized.blob, { maxWidth: 400, quality: 0.8 }, locale.value)\n const thumbnailPath = `${folder}/images/thumbnails/${baseName}.jpg`\n const thumbRef = storageRef(storage, thumbnailPath)\n await uploadBytes(thumbRef, thumbBlob, { contentType: 'image/jpeg', customMetadata: uploadMetadata() })\n const thumbnailUrl = await getDownloadURL(thumbRef)\n\n return { originalUrl, originalPath, thumbnailUrl, thumbnailPath }\n }\n\n /** 원본·썸네일 쌍 삭제 (고아 정리·첨부 삭제용) */\n async function deleteEditorImage(entry: Pick<EditorImageEntry, 'originalPath' | 'thumbnailPath'>): Promise<void> {\n const storage = getStorage(app)\n await Promise.all([\n deleteObject(storageRef(storage, entry.originalPath)).catch(() => {}),\n deleteObject(storageRef(storage, entry.thumbnailPath)).catch(() => {}),\n ])\n }\n\n /**\n * 프로필 사진. 경로가 uid로 고정돼 있어 다시 올리면 그냥 덮어쓴다(고아 파일 없음).\n * 파일은 이미 크롭 단계에서 정사각형 JPEG로 만들어져 오므로 여기선 그대로 업로드한다.\n */\n async function uploadAvatar(uid: string, file: File): Promise<string> {\n const storage = getStorage(app)\n const path = `${cfg().usersCollection}/${uid}/avatar.jpg`\n const fileRef = storageRef(storage, path)\n await uploadBytes(fileRef, file, { contentType: file.type || 'image/jpeg' })\n return getDownloadURL(fileRef)\n }\n\n return {\n uploadAttachment,\n deleteAttachment,\n uploadCommentImage,\n uploadEditorImage,\n deleteEditorImage,\n uploadAvatar,\n }\n}\n"],"mappings":"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"}
|
package/dist/types.d.ts
CHANGED
|
@@ -147,6 +147,11 @@ export interface CommentModel {
|
|
|
147
147
|
isReply?: boolean;
|
|
148
148
|
/** 클라이언트 트랜잭션으로 증감하는 UI 편의 필드. 기존 댓글에는 없을 수 있음(표시 시 0). */
|
|
149
149
|
likeCount?: number;
|
|
150
|
+
/**
|
|
151
|
+
* 댓글 이미지 1장. 없는 기존 댓글은 필드 자체가 없다.
|
|
152
|
+
* Storage: `{postsCollection}/{postId}/comment-images/{commentId}/...`
|
|
153
|
+
*/
|
|
154
|
+
images?: Attachment[];
|
|
150
155
|
}
|
|
151
156
|
export type BoardUserRole = 'owner' | 'admin' | 'manager' | 'staff' | 'user';
|
|
152
157
|
/** memiBoardUsers/{uid} — 이메일만 빼고 전부 공개 프로필 정보다(누구나 읽을 수 있음). */
|
|
@@ -156,8 +161,10 @@ export interface BoardUserModel {
|
|
|
156
161
|
displayName?: string | null;
|
|
157
162
|
photoURL?: string | null;
|
|
158
163
|
moderationBlockCount?: number;
|
|
159
|
-
/** AI 글쓰기 도우미 24시간 사용 횟수. */
|
|
164
|
+
/** AI 글쓰기 도우미 24시간 확정 사용 횟수. 성공한 호출만 올라가고 줄지 않는다. */
|
|
160
165
|
aiWritingCount?: number;
|
|
166
|
+
/** 아직 결과가 나지 않은 AI 글쓰기 예약. 실패하면 줄고, 성공하면 확정 횟수로 넘어간다. */
|
|
167
|
+
aiWritingPending?: number;
|
|
161
168
|
/** AI 글쓰기 도우미 현재 24시간 구간 시작 시각. */
|
|
162
169
|
aiWritingWindowAt?: Timestamp;
|
|
163
170
|
updatedAt?: Timestamp;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { Attachment } from '../types';
|
|
2
|
+
/** 댓글·답글에 둘 수 있는 이미지 수 */
|
|
3
|
+
export declare const COMMENT_IMAGE_LIMIT = 1;
|
|
4
|
+
export declare function isCommentImageFile(file: File): boolean;
|
|
5
|
+
export declare function normalizeCommentImages(images: Attachment[] | null | undefined): Attachment[];
|
|
6
|
+
/** prefix 아래의 댓글 이미지 객체 경로만 모은다. 다른 글의 파일은 빼 둔다. */
|
|
7
|
+
export declare function commentImagePathsUnder(images: unknown, prefix: string): string[];
|
|
@@ -5,5 +5,8 @@ import { BoardPathConfig } from './boardPaths';
|
|
|
5
5
|
export declare function deleteLikesForPost(db: Firestore, cfg: BoardPathConfig, postId: string): Promise<void>;
|
|
6
6
|
/** 댓글 하나에 달린 좋아요만 지운다. */
|
|
7
7
|
export declare function deleteLikesForComment(db: Firestore, cfg: BoardPathConfig, commentId: string): Promise<void>;
|
|
8
|
-
/**
|
|
8
|
+
/**
|
|
9
|
+
* 글 하나의 Storage(본문 이미지, 첨부, 댓글 이미지)를 글 문서가 남아 있는 동안 지운 뒤
|
|
10
|
+
* 댓글·좋아요·본문·메타를 삭제한다. 게시판 삭제도 글마다 이 함수를 호출한다.
|
|
11
|
+
*/
|
|
9
12
|
export declare function deletePostCascade(db: Firestore, storage: FirebaseStorage, cfg: BoardPathConfig, id: string): Promise<void>;
|
|
@@ -55,7 +55,13 @@ var e = {
|
|
|
55
55
|
placeholder: "输入评论",
|
|
56
56
|
submitReply: "回复",
|
|
57
57
|
submit: "发表评论",
|
|
58
|
-
signInToComment: "登录后才能发表评论。"
|
|
58
|
+
signInToComment: "登录后才能发表评论。",
|
|
59
|
+
attachHint: "将图片拖到这里,或点击选择。",
|
|
60
|
+
attachNote: "jpg、png、gif、webp。过大的图片会缩小到 5MB 以下。",
|
|
61
|
+
removeImage: "移除附件图片",
|
|
62
|
+
imageOnly: "只能附加 jpg、png、gif、webp 图片。",
|
|
63
|
+
uploadFailed: "图片上传失败。",
|
|
64
|
+
uploading: "正在上传图片…"
|
|
59
65
|
},
|
|
60
66
|
commentItem: {
|
|
61
67
|
editRestricted: "评论编辑功能已被暂时限制。",
|
|
@@ -64,6 +70,7 @@ var e = {
|
|
|
64
70
|
blindFailed: "无法更改屏蔽状态。",
|
|
65
71
|
likeFailed: "点赞失败。",
|
|
66
72
|
blinded: "该评论已被管理员屏蔽。",
|
|
73
|
+
attachedImage: "评论中的图片",
|
|
67
74
|
edit: "编辑评论",
|
|
68
75
|
blind: "屏蔽评论",
|
|
69
76
|
unblind: "取消屏蔽评论",
|
|
@@ -640,4 +647,4 @@ var e = {
|
|
|
640
647
|
//#endregion
|
|
641
648
|
export { e as default };
|
|
642
649
|
|
|
643
|
-
//# sourceMappingURL=zh-
|
|
650
|
+
//# sourceMappingURL=zh-CtswuQ57.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"zh-CtswuQ57.js","names":[],"sources":["../src/i18n/messages/zh.ts"],"sourcesContent":["import type { MemiBoardMessages } from '../translate'\n\n/** 짧은 화면 문구만 번역한다. 빠진 키는 en → ko 순으로 폴백된다. */\nconst messages: MemiBoardMessages = {\n attachments: {\n attach: '添加附件({count} / {max})',\n maxFiles: '最多可添加 {max} 个附件。',\n },\n auth: {\n writeRestricted: '由于内容警告累计,发帖和评论功能已被暂时限制。',\n strikeSuffix: '(警告 {count}/{threshold})',\n strikeRestricted: '警告已达 {count} 次,发帖和评论功能将被限制。',\n notInitialized: '[memi-board] Firebase Auth 尚未初始化。',\n },\n authorMemo: {\n tooLong: '备注最多 {max} 个字。',\n },\n authorMenu: {\n viewProfile: '查看资料',\n viewPosts: '查看帖子',\n viewComments: '查看评论',\n memoAriaLabel: '关于该作者的备注',\n memoTitle: '仅自己可见的备注',\n memoPlaceholder: '记下关于该作者的内容…',\n bad: '差',\n good: '好',\n },\n avatarCropModal: {\n title: '裁剪头像',\n description: '将以正方形(1:1)显示,请调整区域。',\n confirm: '裁剪并上传',\n },\n boardSettings: {\n signInRequired: '请先登录。',\n idAndNameRequired: '请输入版块 ID 和名称。',\n deleteIdRequired: '需要提供要删除的版块 ID。',\n nameRequired: '请输入版块名称。',\n },\n boardUsers: {\n unsupportedRole: '不支持该角色。',\n roleDescription: {\n admin: '管理用户角色、版块设置及所有内容。',\n user: '可发帖和评论,仅能编辑或删除自己的内容。',\n },\n },\n commentFeed: {\n empty: '暂无评论。',\n emptyLiked: '暂无被点赞的评论。',\n loadFailed: '无法加载列表:{error}',\n blinded: '该评论已被管理员屏蔽。',\n },\n commentForm: {\n hiddenBoardDenied: '隐藏版块仅限负责的工作人员评论。',\n denied: '你没有在此版块评论的权限。',\n signInRequired: '请先登录。',\n writeRestricted: '由于内容警告累计,发帖和评论功能已被暂时限制。',\n moderationBlocked: '包含无法发布的内容。',\n replyTo: '回复 {name}',\n placeholder: '输入评论',\n submitReply: '回复',\n submit: '发表评论',\n signInToComment: '登录后才能发表评论。',\n attachHint: '将图片拖到这里,或点击选择。',\n attachNote: 'jpg、png、gif、webp。过大的图片会缩小到 5MB 以下。',\n removeImage: '移除附件图片',\n imageOnly: '只能附加 jpg、png、gif、webp 图片。',\n uploadFailed: '图片上传失败。',\n uploading: '正在上传图片…',\n },\n commentItem: {\n editRestricted: '评论编辑功能已被暂时限制。',\n moderationBlocked: '包含无法发布的内容。',\n editFailed: '无法修改评论。',\n blindFailed: '无法更改屏蔽状态。',\n likeFailed: '点赞失败。',\n blinded: '该评论已被管理员屏蔽。',\n attachedImage: '评论中的图片',\n edit: '编辑评论',\n blind: '屏蔽评论',\n unblind: '取消屏蔽评论',\n delete: '删除评论',\n },\n commentList: {\n loading: '正在加载评论',\n empty: '暂无评论。',\n loadMore: '更多评论',\n loadingMore: '正在加载更多评论',\n },\n commentThread: {\n deleteConfirm: '确定要删除这条评论吗?',\n deleteFailed: '无法删除评论。',\n hideReplies: '收起回复',\n showReplies: '{count} 条回复',\n loadMoreReplies: '更多回复',\n },\n comments: {\n bodyRequired: '请输入评论内容。',\n bodyTooLong: '评论最多 {max} 个字。',\n replyTargetNotFound: '找不到要回复的评论。',\n threadNotFound: '找不到该评论串。',\n hasRepliesCannotDelete: '有回复的评论目前无法删除。',\n },\n common: {\n action: {\n cancel: '取消',\n save: '保存',\n saved: '已保存',\n edit: '编辑',\n delete: '删除',\n confirm: '确定',\n close: '关闭',\n add: '添加',\n more: '更多',\n prev: '上一篇',\n next: '下一篇',\n retry: '重试',\n reply: '回复',\n like: '点赞',\n unlike: '取消点赞',\n share: '分享',\n report: '举报',\n signIn: '登录',\n signOut: '退出登录',\n write: '发帖',\n toTop: '顶部',\n },\n label: {\n anonymous: '匿名',\n user: '用户',\n image: '图片',\n draft: '草稿',\n loading: '加载中…',\n },\n role: {\n admin: '管理员',\n staff: '工作人员',\n user: '用户',\n },\n date: {\n justNow: '刚刚',\n fewSecondsAgo: '几秒前',\n checking: '正在确认时间',\n createdAt: '创建时间:{time}',\n updatedAt: '修改时间:{time}',\n },\n },\n detail: {\n deleteConfirm: '确定要删除这篇帖子吗?此操作无法撤销。',\n notFound: '找不到该帖子。',\n draftPreview: '未发布预览',\n draftNotice: '尚未发布。发布前仅作者本人可见。',\n publish: '发布',\n backToList: '返回列表',\n postNav: '帖子导航',\n list: '列表',\n comments: '评论',\n },\n editor: {\n titlePlaceholder: '标题',\n contentPlaceholder: '输入正文… 输入 / 可打开块菜单。',\n imageContentPlaceholder: '输入内容…',\n tagsPlaceholder: '输入标签后按 Enter',\n skipModeration: '跳过敏感词过滤(管理员)',\n submitEdit: '完成修改',\n submitNext: '下一步:预览',\n apply: '应用',\n imageOptimizing: '正在优化并上传图片…',\n cover: {\n imageLink: '图片链接',\n addPhoto: '添加照片',\n alt: '照片 {n}',\n representative: '封面',\n setRepresentative: '设为封面',\n setRepresentativeAria: '将照片 {n} 设为封面',\n remove: '移除照片',\n add: '添加',\n uploading: '上传中…',\n dropOrClick: '拖放照片或点击添加',\n emptyHint: '可多选 · 上传后选择封面 · 拖动调整顺序',\n uploadTitle: '上传',\n },\n uploadFailed: '上传失败',\n aiAssistant: 'AI 写作助手',\n insertBlock: '插入块',\n blockMenu: '块菜单',\n imageDialog: {\n title: '添加图片',\n chooseFile: '从设备中选择图片',\n orLink: '或使用图片链接',\n urlLabel: '图片 URL',\n previewAlt: '外部图片预览',\n addLink: '添加链接',\n },\n customDialog: {\n title: '改写为自定义风格',\n label: '想怎么改写?',\n help: '例如:用东北话风格 / 用正式书面语 / 改成简洁的要点',\n placeholder: '用东北话风格',\n },\n aiPreview: {\n title: '确认 AI 结果',\n currentTitle: '当前标题',\n newTitle: '新标题',\n currentContent: '当前内容',\n appendedContent: '将添加的内容',\n before: '修改前',\n after: '修改后',\n },\n menu: {\n ai: 'AI',\n aiContinue: '用 AI 续写',\n style: '样式',\n insert: '插入',\n paragraph: '正文',\n heading1: '标题 1',\n heading2: '标题 2',\n heading3: '标题 3',\n bulletList: '无序列表',\n orderedList: '有序列表',\n blockquote: '引用',\n codeBlock: '代码块',\n horizontalRule: '分隔线',\n image: '图片',\n turnInto: '转换为',\n resetFormatting: '重置格式',\n duplicate: '复制',\n moveUp: '上移',\n moveDown: '下移',\n delete: '删除',\n },\n toolbar: {\n link: '链接',\n image: '图片',\n format: '格式',\n bold: '粗体',\n italic: '斜体',\n underline: '下划线',\n strike: '删除线',\n title: '标题',\n heading: '大标题',\n subheading: '小标题',\n body: '正文',\n mono: '等宽',\n bulletList: '无序列表',\n orderedList: '有序列表',\n blockquote: '块引用',\n codeBlock: '代码块',\n horizontalRule: '分隔线',\n clearFormatting: '清除格式',\n undo: '撤销',\n redo: '重做',\n },\n ai: {\n proofread: '拼写检查',\n polish: '润色文字',\n continue: '自然续写',\n summarize: '简短总结',\n translateEn: '翻译成英语',\n translateKo: '翻译成韩语',\n translateAll: '翻译并保存为10种语言',\n translateSaved: '已保存为10种语言。用其他语言查看时会看到译文。',\n translatePending: '已准备好10种语言的翻译。保存文章时会一并记录。',\n translateEmpty: '请先输入要翻译的标题或正文。',\n title: '根据正文生成标题',\n custom: '改写为自定义风格…',\n failed: 'AI 处理失败,请重试。',\n },\n youtubePrompt: '请输入 YouTube 链接。',\n errors: {\n coverMax: '最多可上传 {max} 张照片。',\n imageUrlRequired: '请输入图片地址。',\n imageUrlInvalid: '请输入有效的图片地址。',\n imageUrlHttps: '仅支持 HTTPS 图片地址。',\n imageUrlCredentials: '不能使用包含登录信息的地址。',\n imageTimeout: '图片加载时间过长。',\n imageNotDisplayable: '无法显示该图片。',\n editorNotReady: '编辑器未准备就绪,请重试。',\n imageLinkFailed: '无法添加图片链接。',\n imageOnly: '只能上传图片文件。',\n imageTooLarge: '原始图片不能超过 {mb}MB。',\n imageUploadFailed: '图片上传失败。',\n pasteAsFile: '请将照片以文件形式粘贴或拖到上方区域。',\n postNotFound: '找不到该帖子。',\n loadPermission: '没有加载该帖子的权限,请检查登录状态。',\n loadFailed: '无法加载帖子:{message}',\n titleRequired: '请输入标题。',\n photoRequired: '请至少上传一张照片。',\n bodyRequired: '请在正文中输入文字或添加图片。',\n signInRequired: '请先登录。',\n boardMissing: '未指定版块。',\n writeRestricted: '由于内容警告累计,发帖和评论功能已被暂时限制。',\n moderationBlocked: '包含无法发布的内容。',\n },\n hint: {\n reviewing: '正在审核内容…',\n saving: '正在保存…',\n },\n },\n footer: {\n info: {\n title: '版块信息',\n about: '关于本版块',\n history: '版本历史',\n },\n stack: {\n ui: '界面与体验',\n data: '数据与登录',\n writing: '写作与便捷功能',\n },\n },\n likes: {\n signInRequired: '请先登录。',\n commentNotFound: '找不到该评论。',\n },\n list: {\n settings: '设置',\n loadFailed: '无法加载列表:{error}',\n empty: '暂无帖子。',\n photo: '照片',\n likeCount: '点赞 {count}',\n info: {\n title: '版块信息',\n about: '关于本版块',\n history: '版本历史',\n },\n stack: {\n framework: '框架与语言',\n ui: 'UI 与样式',\n backend: '后端与数据',\n },\n tech: {\n nuxt: '基于 Vue 的 SSR 框架',\n vue: 'Composition API · script setup',\n typescript: '类型安全的数据与组件',\n nuxtUi: '版块、表单与弹窗 UI',\n tailwind: '响应式 · 浅色/深色样式',\n lucide: '轻量且风格统一的图标',\n dayjs: '易读的日期与相对时间',\n firestore: '用于帖子和评论的实时数据库',\n auth: '安全的用户登录与权限',\n storage: '图片与附件存储',\n vuefire: 'Nuxt 与 Firebase 实时连接',\n },\n },\n listViewSwitch: {\n default: '默认',\n dense: '紧凑',\n video: '视频',\n },\n miniComments: {\n empty: '暂无评论。',\n blinded: '该评论已被管理员屏蔽。',\n },\n miniList: {\n empty: '暂无帖子。',\n },\n miniPicker: {\n selectedCount: '已选 {count} / {max}',\n layoutHint: '2 篇起为轮播,4 篇为网格',\n searchPlaceholder: '搜索标题',\n loading: '正在加载帖子…',\n noMore: '没有更多可选的帖子了。',\n },\n miniPost: {\n empty: '未选择帖子。',\n },\n moderation: {\n writeRestricted: '由于内容警告累计,发帖和评论功能已被暂时限制。',\n },\n moderationPrompt: {\n localHit: '不能使用“{hit}”之类的表达,请换种说法。',\n localGeneric: '内容包含脏话或粗俗用语,无法发布,请修改措辞。',\n genericBlocked: '包含无法发布的内容,请修改后再试。',\n categories: {\n abuse: '脏话、粗俗用语或侮辱',\n spam: '垃圾信息或广告',\n adult: '色情或成人内容',\n violence: '暴力或威胁',\n other: '不当内容',\n },\n },\n moderationStrike: {\n liftSoon: '即将解除',\n liftInHours: '约 {hours} 小时后解除',\n liftInDays: '约 {days} 天后解除',\n liftInDaysHours: '约 {days} 天 {hours} 小时后解除',\n },\n posts: {\n boardIdRequired: '[memi-board] 需要提供 boardId。',\n photoRequired: '请上传照片。',\n bodyRequired: '请在正文中输入文字或添加图片。',\n titleRequired: '请输入标题。',\n },\n profile: {\n notFound: '找不到该用户。',\n noName: '未命名',\n joined: '{time} 加入',\n lastVisit: '最近访问 {time}',\n },\n profileEditModal: {\n imageOnly: '只能选择图片文件。',\n nameRequired: '请输入名称。',\n saveFailed: '无法保存个人资料。',\n title: '编辑个人资料',\n changePhoto: '更换照片',\n name: '名称',\n bio: '个人简介',\n bioPlaceholder: '介绍一下自己吧',\n },\n reportButton: {\n failed: '举报失败。',\n reported: '已举报',\n title: '举报帖子',\n done: '举报已提交,工作人员将进行审核。',\n reason: '原因',\n detailPlaceholder: '详细说明(选填)',\n submit: '举报',\n },\n reportList: {\n staffOnly: '仅工作人员可查看举报列表。',\n loadFailed: '无法加载列表:{error}',\n empty: '暂无待处理的举报。',\n untitled: '无标题帖子',\n author: '作者 {name}',\n dismiss: '驳回',\n confirm: '已处理',\n },\n reports: {\n reason: {\n spam: '垃圾信息/广告',\n abuse: '辱骂/仇恨/骚扰',\n adult: '色情内容',\n illegal: '违法信息',\n other: '其他',\n },\n fallbackLabel: '举报',\n signInRequired: '请先登录。',\n cannotReportOwn: '不能举报自己的帖子。',\n reasonRequired: '请选择举报原因。',\n detailTooLong: '详细说明最多 {max} 个字。',\n },\n sectionUtil: {\n kind: {\n list: '帖子列表',\n post: '帖子',\n comments: '评论合集',\n },\n sort: {\n latest: '最新',\n likes: '最多点赞',\n },\n untitledPost: '帖子',\n view: {\n media: '媒体',\n list: '列表',\n },\n },\n sections: {\n defaultTitle: '区块 {n}',\n },\n seo: {\n allCategory: '全部',\n boardDescription: '{name} 版块',\n allDescription: '版块 — 查看公告和社区动态。',\n postFallbackTitle: '帖子 | {site}',\n postFallbackDescription: '版块',\n photo: '照片',\n postDescriptionFallback: '版块帖子',\n listTitle: '{name} 版块 | {site}',\n listTitleAll: '版块 | {site}',\n listDescription: '{name} 版块 — 查看最新动态。',\n listDescriptionAll: '版块 — 查看公告和话题。',\n },\n settings: {\n loading: '正在加载设置。',\n noAccess: '没有访问权限',\n notFound: {\n title: '版块不存在',\n checkAddress: '请检查地址是否正确。',\n checkBoardAddress: '请检查版块地址是否正确。',\n },\n newBoard: {\n title: '该版块尚未创建',\n description: '填写以下信息并保存后,将以此 ID 新建版块。',\n },\n noLabel: '无标签',\n option: {\n default: '默认',\n },\n id: {\n label: '分类 ID',\n help: '用于网址和数据的标识',\n },\n writeRole: {\n label: '发帖权限',\n help: '可在此分类发帖的最低角色',\n helpShort: '可发帖的最低角色',\n options: {\n user: '普通用户及以上',\n staff: '工作人员及以上',\n admin: '仅管理员',\n },\n },\n commentWriteRole: {\n label: '评论权限',\n help: '可在此分类评论的最低角色',\n helpShort: '可评论的最低角色',\n hiddenFixed: '仅负责的工作人员(隐藏版块固定)',\n },\n staff: {\n label: '负责的工作人员',\n help: '仅指定的工作人员可设置和管理此版块。留空则仅限管理员',\n placeholder: '仅管理员(未指定工作人员)',\n },\n label: {\n label: '显示名称',\n help: '用户看到的名称',\n },\n description: {\n label: '说明',\n help: '显示在版块顶部等位置的提示文字',\n placeholder: '关于此版块的简短说明(选填)',\n },\n visibility: {\n label: '可见范围',\n options: {\n public: {\n label: '可见',\n description: '显示在全部列表和筛选中',\n },\n hidden: {\n label: '隐藏',\n description: '日记、私密用途。仅负责的工作人员可评论',\n },\n },\n },\n listView: {\n label: '列表视图',\n help: '帖子列表的显示方式',\n options: {\n dense: '紧凑',\n video: '视频',\n },\n },\n editorType: {\n label: '输入表单',\n help: '发帖页面的编写方式',\n options: {\n default: '填写标题和正文',\n image: '上传多张照片并设置封面',\n },\n },\n locale: {\n label: '版块语言',\n auto: '自动(跟随网站语言)',\n },\n autoTranslate: {\n action: \"翻译为10种语言\",\n manualHelp: \"一起翻译显示名称和说明,翻译后请点击保存以应用。\",\n pending: \"10种语言的翻译已就绪,请点击保存以应用。\",\n manualFailed: \"翻译失败,输入内容已保留,请重试。\",\n label: '保存时翻译成10种语言',\n help: '打开后,保存时会把名称和说明翻译成10种语言。关闭时不翻译。文字没变就不会再次翻译。',\n failed: '设置已保存,但翻译成10种语言失败了。请再次保存。',\n ready: '名称和说明已保存为10种语言。',\n },\n action: {\n moveDown: '下移',\n goToBoard: '前往版块',\n deleteBoard: '删除版块',\n create: '创建',\n },\n newCategoryPlaceholder: '新分类名称',\n savedCategory: '已保存“{label}”分类。',\n savedSettings: '版块设置已保存。',\n created: '版块已创建。',\n error: {\n orderFailed: '无法保存分类顺序。',\n deleteFailed: '无法删除版块及其关联数据。',\n saveFailed: '无法保存版块设置。',\n deleteAllFailed: '无法删除版块数据。',\n },\n danger: {\n title: '危险区域',\n confirmLabel: '请输入“{phrase}”以确认',\n phrase: '删除版块数据',\n button: '删除所有数据',\n done: '已删除所有版块数据。',\n },\n },\n shareButton: {\n copied: '链接已复制',\n },\n signIn: {\n withGoogle: '使用 Google 登录',\n withApple: '使用 Apple 登录',\n or: '或',\n name: '名称',\n email: '邮箱',\n password: '密码',\n signUp: '注册',\n toSignUp: '还没有账号?注册',\n toSignIn: '已有账号?登录',\n },\n storage: {\n sourceTooLarge: '原始图片不能超过 {max}MB。',\n imageOnly: '只能上传图片文件。',\n canvasUnavailable: '无法使用 Canvas',\n compressFailed: '压缩失败',\n loadFailed: '图片加载失败',\n },\n swipeHint: {\n title: '试试左右滑动',\n description: '向左滑动查看下一篇,向右滑动查看上一篇。',\n },\n userCommentList: {\n loadFailed: '无法加载列表:{error}',\n empty: '暂无评论。',\n blinded: '该评论已被管理员屏蔽。',\n likes: '点赞 {count}',\n },\n userPostList: {\n loadFailed: '无法加载列表:{error}',\n empty: '暂无帖子。',\n },\n users: {\n changeRoleFailed: '无法更改角色。',\n loading: '正在加载用户列表。',\n noPermission: {\n title: '没有权限',\n description: '仅版块管理员可管理用户角色。',\n },\n empty: '暂无登录过此版块的用户。',\n noName: '未命名',\n moderationWarnings: '内容警告 {count} 次',\n },\n writingAssistant: {\n loginRequired: '登录后才能使用 AI 写作助手。',\n userMissing: '请确认用户信息后重试。',\n contentRequired: '请先输入要让 AI 润色的内容。',\n customRequired: '请输入改写要求。',\n emptyResult: 'AI 未能生成结果,请重试。',\n translateEmpty: '请先输入要翻译的标题或正文。',\n translateTooLong: '正文超过{limit}字时无法保存为10种语言。请缩短后再试。',\n translateIncomplete: '部分语言的翻译是空的。请重试。',\n failed: 'AI 处理失败,请重试。',\n actions: {\n proofread: '拼写检查',\n polish: '润色文字',\n continue: '自然续写',\n summarize: '简短总结',\n translateEn: '翻译成英语',\n translateKo: '翻译成韩语',\n translateAll: '翻译并保存为10种语言',\n translateSaved: '已保存为10种语言。用其他语言查看时会看到译文。',\n translatePending: '已准备好10种语言的翻译。保存文章时会一并记录。',\n translateEmpty: '请先输入要翻译的标题或正文。',\n title: '根据正文生成标题',\n custom: '改写为自定义风格…',\n },\n },\n}\n\nexport default messages\n"],"mappings":"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"}
|