v1.2.1: 블록 설정 패널·이미지 alt 토글 및 포커스 수정
게시물 설정 사이드바 오버레이로 이미지·갤러리·임베드를 편집하고, 파일명 alt 토글과 패널 입력 중 닫힘 문제를 해결했다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
219
components/admin/AdminEditorBlockPanel.vue
Normal file
219
components/admin/AdminEditorBlockPanel.vue
Normal file
@@ -0,0 +1,219 @@
|
||||
<script setup>
|
||||
import { getImageAltAttribute, getImageDefaultAltLabel } from '../../lib/markdown-image.js'
|
||||
|
||||
const props = defineProps({
|
||||
/** 패널 표시 여부 */
|
||||
open: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
/** 활성 블록 컨텍스트 */
|
||||
panel: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'panel-focus-in',
|
||||
'panel-focus-out',
|
||||
'update-media-image',
|
||||
'set-media-use-alt',
|
||||
'move-gallery-image',
|
||||
'remove-media-image',
|
||||
'add-gallery-images',
|
||||
'update-embed-url'
|
||||
])
|
||||
|
||||
/**
|
||||
* 블록 종류 라벨
|
||||
* @returns {string}
|
||||
*/
|
||||
const panelTitle = computed(() => {
|
||||
if (!props.panel) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (props.panel.kind === 'gallery') {
|
||||
return '갤러리'
|
||||
}
|
||||
|
||||
if (props.panel.kind === 'embed') {
|
||||
return '임베드'
|
||||
}
|
||||
|
||||
return '이미지'
|
||||
})
|
||||
|
||||
/**
|
||||
* 블록 종류 부제
|
||||
* @returns {string}
|
||||
*/
|
||||
const panelMeta = computed(() => {
|
||||
if (!props.panel) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (props.panel.kind === 'gallery') {
|
||||
return `${props.panel.images.length}개 이미지`
|
||||
}
|
||||
|
||||
if (props.panel.kind === 'embed') {
|
||||
return 'YouTube·X 등 URL'
|
||||
}
|
||||
|
||||
return '커서가 위치한 이미지 줄'
|
||||
})
|
||||
|
||||
/**
|
||||
* 포커스가 패널 밖으로 나갔을 때만 이탈 이벤트를 보낸다.
|
||||
* @param {FocusEvent} event - focusout 이벤트
|
||||
* @returns {void}
|
||||
*/
|
||||
const onPanelFocusOut = (event) => {
|
||||
const root = event.currentTarget
|
||||
const next = event.relatedTarget
|
||||
|
||||
if (next instanceof Node && root.contains(next)) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('panel-focus-out')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside
|
||||
class="admin-editor-block-panel absolute inset-0 z-20 flex flex-col bg-white shadow-[-8px_0_24px_rgba(15,23,42,0.08)] transition-transform duration-300 ease-out"
|
||||
:class="open ? 'translate-x-0' : 'translate-x-full pointer-events-none'"
|
||||
:aria-hidden="!open"
|
||||
@focusin="emit('panel-focus-in')"
|
||||
@focusout="onPanelFocusOut"
|
||||
>
|
||||
<div v-if="panel" class="admin-editor-block-panel__inner flex h-full flex-col">
|
||||
<header class="admin-editor-block-panel__header flex h-[56px] shrink-0 items-center justify-between border-b border-[#e3e6e8] px-6">
|
||||
<div>
|
||||
<h2 class="admin-editor-block-panel__title text-xl font-bold text-black">
|
||||
{{ panelTitle }}
|
||||
</h2>
|
||||
<p class="admin-editor-block-panel__meta mt-1 text-xs text-[#6b7280]">
|
||||
{{ panelMeta }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="panel.kind === 'gallery'"
|
||||
class="admin-editor-block-panel__add rounded bg-[#15171a] px-3 py-1.5 text-xs font-semibold text-white"
|
||||
type="button"
|
||||
@click="emit('add-gallery-images')"
|
||||
>
|
||||
이미지 추가
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="admin-editor-block-panel__body flex-1 overflow-y-auto px-6 py-6">
|
||||
<template v-if="panel.kind === 'embed'">
|
||||
<label class="admin-editor-block-panel__field grid gap-2 text-sm">
|
||||
<span class="font-semibold text-[#394047]">임베드 URL</span>
|
||||
<input
|
||||
class="rounded border border-[#d7dde2] bg-[#eff1f2] px-3 py-2 text-sm text-[#15171a] outline-none transition-colors focus:border-[#8e9cac]"
|
||||
:value="panel.url"
|
||||
type="url"
|
||||
placeholder="https://www.youtube.com/watch?v=..."
|
||||
@input="emit('update-embed-url', $event.target.value)"
|
||||
>
|
||||
</label>
|
||||
<p class="admin-editor-block-panel__hint mt-3 text-xs leading-relaxed text-[#8e9cac]">
|
||||
YouTube·YouTube Shorts, X(트위터) 게시물 URL을 지원합니다. 그 외 URL은 링크 카드로 표시됩니다.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="admin-editor-block-panel__media-list grid gap-3">
|
||||
<div
|
||||
v-for="(image, imageIndex) in panel.images"
|
||||
:key="`block-panel-image-${imageIndex}`"
|
||||
class="admin-editor-block-panel__media-row grid gap-3 rounded border border-[#edf0f2] bg-[#fafafa] p-3"
|
||||
>
|
||||
<img
|
||||
class="aspect-[16/10] w-full rounded bg-[#eff1f2] object-cover"
|
||||
:src="image.url"
|
||||
:alt="getImageAltAttribute(image)"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<label class="grid gap-1 text-xs font-semibold text-[#394047]">
|
||||
캡션
|
||||
<input
|
||||
class="rounded border border-[#d7dde2] bg-white px-3 py-2 text-sm font-normal text-[#15171a] placeholder:font-normal placeholder:text-[#8e9cac]"
|
||||
:value="image.caption || ''"
|
||||
type="text"
|
||||
placeholder="비우면 표시하지 않음"
|
||||
@input="emit('update-media-image', imageIndex, { caption: $event.target.value })"
|
||||
>
|
||||
</label>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<label class="inline-flex cursor-pointer items-center gap-2 text-xs font-semibold text-[#394047]">
|
||||
<input
|
||||
class="size-3.5 rounded border-[#c8ced3] text-[#15171a]"
|
||||
type="checkbox"
|
||||
:checked="image.useAlt"
|
||||
@change="emit('set-media-use-alt', imageIndex, $event.target.checked)"
|
||||
>
|
||||
파일명을 대체 텍스트로 사용
|
||||
</label>
|
||||
</div>
|
||||
<p
|
||||
v-if="image.useAlt"
|
||||
class="text-[11px] font-normal text-[#8e9cac]"
|
||||
>
|
||||
{{ getImageDefaultAltLabel(image.url) || '(파일명 없음)' }}
|
||||
</p>
|
||||
<label class="grid gap-1 text-xs font-semibold text-[#394047]">
|
||||
이미지 URL
|
||||
<input
|
||||
class="rounded border border-[#d7dde2] bg-white px-3 py-2 text-sm font-normal text-[#15171a]"
|
||||
:value="image.url"
|
||||
type="text"
|
||||
@input="emit('update-media-image', imageIndex, { url: $event.target.value })"
|
||||
>
|
||||
</label>
|
||||
<div v-if="panel.kind === 'gallery'" class="flex flex-wrap gap-2">
|
||||
<button
|
||||
class="rounded px-2.5 py-1.5 text-xs font-semibold text-[#394047] hover:bg-[#eff1f2] disabled:opacity-40"
|
||||
type="button"
|
||||
:disabled="imageIndex === 0"
|
||||
@click="emit('move-gallery-image', imageIndex, -1)"
|
||||
>
|
||||
위로
|
||||
</button>
|
||||
<button
|
||||
class="rounded px-2.5 py-1.5 text-xs font-semibold text-[#394047] hover:bg-[#eff1f2] disabled:opacity-40"
|
||||
type="button"
|
||||
:disabled="imageIndex === panel.images.length - 1"
|
||||
@click="emit('move-gallery-image', imageIndex, 1)"
|
||||
>
|
||||
아래로
|
||||
</button>
|
||||
<button
|
||||
class="rounded px-2.5 py-1.5 text-xs font-semibold text-red-600 hover:bg-red-50"
|
||||
type="button"
|
||||
@click="emit('remove-media-image', imageIndex)"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
class="rounded px-2.5 py-1.5 text-xs font-semibold text-red-600 hover:bg-red-50"
|
||||
type="button"
|
||||
@click="emit('remove-media-image', imageIndex)"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
Reference in New Issue
Block a user