feat: 에디터 이미지 = 서버 업로드(URL) + 업로드 안내 메시지
CI / build (push) Successful in 35s

- PC 에디터: 🖼 → 압축 → 서버 업로드 → 본문에 ![](/api/images/{id}) 삽입(base64 제거)
- Toast UI(웹/APK): addImageBlobHook 으로 동일하게 서버 업로드(base64 대체)
- 업로드 중/실패 안내 메시지 표시

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-06-07 19:17:44 +09:00
parent 0238e0b22f
commit bfba296b91
2 changed files with 50 additions and 13 deletions
+9
View File
@@ -26,6 +26,15 @@ export const boardApi = {
remove(id) { remove(id) {
return http.delete(`/board/posts/${id}`) return http.delete(`/board/posts/${id}`)
}, },
// 본문 인라인 이미지 업로드(서버 DB 저장) → { url } (예: /api/images/123)
uploadImage(blob) {
const fd = new FormData()
fd.append('image', blob, 'image.jpg')
return http.post('/board/images', fd, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 30000,
})
},
tags() { tags() {
return http.get('/board/tags') return http.get('/board/tags')
}, },
+41 -13
View File
@@ -7,6 +7,7 @@ import 'prismjs/themes/prism-tomorrow.css'
import '@toast-ui/editor-plugin-code-syntax-highlight/dist/toastui-editor-plugin-code-syntax-highlight.css' import '@toast-ui/editor-plugin-code-syntax-highlight/dist/toastui-editor-plugin-code-syntax-highlight.css'
import MarkdownViewer from '@/components/editor/MarkdownViewer.vue' import MarkdownViewer from '@/components/editor/MarkdownViewer.vue'
import { imageToBlob } from '@/utils/receiptOcr' import { imageToBlob } from '@/utils/receiptOcr'
import { boardApi } from '@/api/boardApi'
const props = defineProps({ const props = defineProps({
modelValue: { type: String, default: '' }, modelValue: { type: String, default: '' },
@@ -104,32 +105,37 @@ function insertLink() {
}) })
} }
// 이미지: 파일 선택 → 리사이즈/압축(JPEG) → base64 임베드 (웹 에디터와 동일하게 본문에 포함, 별도 서버 저장 없음) // 이미지: 파일 → 리사이즈/압축(JPEG) → 서버 업로드(DB 저장) → 본문엔 /api/images/{id} URL 만 삽입
const imgInput = ref(null) const imgInput = ref(null)
const imgBusy = ref(false) const imgBusy = ref(false)
const imgMsg = ref('')
let imgMsgTimer = null
function flashImgMsg(msg) {
imgMsg.value = msg
if (imgMsgTimer) clearTimeout(imgMsgTimer)
imgMsgTimer = setTimeout(() => (imgMsg.value = ''), 4000)
}
function pickImage() { function pickImage() {
imgInput.value?.click() imgInput.value?.click()
} }
function blobToDataUrl(blob) { // 압축 후 업로드 → URL 반환 (PC textarea·Toast UI 공용)
return new Promise((resolve, reject) => { async function uploadCompressed(fileOrBlob) {
const r = new FileReader() const blob = await imageToBlob(fileOrBlob, 1280) // 긴 변 1280px, JPEG 0.85
r.onload = () => resolve(r.result) const { url } = await boardApi.uploadImage(blob)
r.onerror = reject return url
r.readAsDataURL(blob)
})
} }
async function onImagePick(e) { async function onImagePick(e) {
const file = e.target.files?.[0] const file = e.target.files?.[0]
e.target.value = '' e.target.value = ''
if (!file) return if (!file) return
imgBusy.value = true imgBusy.value = true
imgMsg.value = '이미지 업로드 중…'
try { try {
const blob = await imageToBlob(file, 1280) // 긴 변 1280px, JPEG 0.85 압축 const url = await uploadCompressed(file)
const dataUrl = await blobToDataUrl(blob)
const ta = taRef.value const ta = taRef.value
const s = ta ? ta.selectionStart : text.value.length const s = ta ? ta.selectionStart : text.value.length
const alt = file.name.replace(/\.[^.]+$/, '') const alt = file.name.replace(/\.[^.]+$/, '')
const md = `![${alt}](${dataUrl})` const md = `![${alt}](${url})`
text.value = text.value.slice(0, s) + md + text.value.slice(s) text.value = text.value.slice(0, s) + md + text.value.slice(s)
nextTick(() => { nextTick(() => {
if (!ta) return if (!ta) return
@@ -137,8 +143,9 @@ async function onImagePick(e) {
const pos = s + md.length const pos = s + md.length
ta.setSelectionRange(pos, pos) ta.setSelectionRange(pos, pos)
}) })
} catch { imgMsg.value = ''
/* 이미지 처리 실패 무시 */ } catch (err) {
flashImgMsg(err?.response?.data?.message || '이미지 업로드에 실패했습니다.')
} finally { } finally {
imgBusy.value = false imgBusy.value = false
} }
@@ -167,6 +174,18 @@ onMounted(() => {
emit('update:modelValue', lastEmitted) emit('update:modelValue', lastEmitted)
}, },
}, },
hooks: {
// 이미지 추가 시 base64 대신 서버 업로드 → URL 삽입
addImageBlobHook: async (blob, callback) => {
try {
const url = await uploadCompressed(blob)
callback(url, '')
} catch {
/* 업로드 실패 시 미삽입 */
}
return false
},
},
}) })
}) })
@@ -216,6 +235,7 @@ onBeforeUnmount(() => {
<button type="button" title="구분선" @click="insertBlock('---')"></button> <button type="button" title="구분선" @click="insertBlock('---')"></button>
<input ref="imgInput" type="file" accept="image/*" hidden @change="onImagePick" /> <input ref="imgInput" type="file" accept="image/*" hidden @change="onImagePick" />
</div> </div>
<p v-if="imgMsg" class="mde-imgmsg">{{ imgMsg }}</p>
<textarea <textarea
ref="taRef" ref="taRef"
v-model="text" v-model="text"
@@ -298,6 +318,14 @@ onBeforeUnmount(() => {
margin: 0 0.25rem; margin: 0 0.25rem;
background: var(--color-border); background: var(--color-border);
} }
.mde-imgmsg {
margin: 0;
padding: 0.4rem 0.9rem;
font-size: 0.82rem;
color: hsla(160, 100%, 32%, 1);
background: hsla(160, 100%, 37%, 0.08);
border-bottom: 1px solid var(--color-border);
}
.mde-textarea { .mde-textarea {
display: block; display: block;
width: 100%; width: 100%;