Files
sori.studio/components/admin/AdminEditorBlockPanel.vue

852 lines
32 KiB
Vue

<script setup>
import { getImageAltAttribute, getImageDefaultAltLabel } from '../../lib/markdown-image.js'
import {
CALLOUT_BACKGROUND_OPTIONS,
CALLOUT_BACKGROUND_SWATCHES,
CALLOUT_EMOJI_OPTIONS,
QUOTE_BACKGROUND_LABELS,
QUOTE_BACKGROUND_OPTIONS,
QUOTE_BACKGROUND_SWATCHES
} from '../../lib/markdown-callout.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',
'update-quote-background',
'update-callout-options',
'update-code-options',
'update-toggle-options',
'update-inline-color'
])
/** @type {number} 단일 이미지 직접 너비 최소값 */
const IMAGE_WIDTH_MIN = 120
/** @type {number} 단일 이미지 직접 너비 최대값 */
const IMAGE_WIDTH_MAX = 1600
/** @type {Array<{ label: string, value: string, pixelWidth: number|null }>} 단일 이미지 대표 너비 옵션 */
const IMAGE_WIDTH_PRESET_OPTIONS = [
{ label: '전체 너비(자동)', value: 'auto', pixelWidth: null },
{ label: '갤러리 2장 1칸(356px)', value: '356', pixelWidth: 356 },
{ label: '갤러리 3장 1칸(235px)', value: '235', pixelWidth: 235 },
{ label: '갤러리 3장 2칸(477px)', value: '477', pixelWidth: 477 },
{ label: '직접 입력', value: 'custom', pixelWidth: null }
]
/** @type {import('vue').Ref<Set<string>>} 직접 입력으로 열어둔 이미지 너비 상태 */
const customImageWidthKeys = ref(new Set())
/** @type {import('vue').Ref<Record<string, string>>} 직접 너비 입력 중인 원본 문자열 */
const imageWidthDraftValues = ref({})
const backgroundLabels = {
gray: '회색',
blue: '파랑',
green: '초록',
yellow: '노랑',
red: '빨강',
purple: '보라'
}
const backgroundSwatches = {
...CALLOUT_BACKGROUND_SWATCHES
}
const inlineColorSwatches = {
gray: '#6b7280',
blue: '#0055ff',
green: '#16ae68',
yellow: '#a16207',
red: '#e60000',
purple: '#8800ff'
}
/** @type {Array<{ label: string, value: string }>} 코드 블록 언어 셀렉트 옵션 */
const CODE_LANGUAGE_OPTIONS = [
{ label: '일반 텍스트', value: '' },
{ label: 'JavaScript', value: 'javascript' },
{ label: 'HTML', value: 'html' },
{ label: 'CSS', value: 'css' },
{ label: 'Vue', value: 'vue' },
{ label: 'JSON', value: 'json' },
{ label: 'Bash', value: 'bash' },
{ label: 'Markdown', value: 'markdown' },
{ label: 'SQL', value: 'sql' }
]
/** @type {number|null} 패널 내부 포인터 조작 상태 해제 타이머 */
let panelPointerInsideTimer = null
/** @type {boolean} 패널 내부 포인터 조작 중 여부 */
let isPanelPointerInside = false
/**
* 배경 라벨을 반환한다.
* @param {string} background - 배경 키
* @returns {string} 배경 라벨
*/
const getBackgroundLabel = (background) => backgroundLabels[background] || background
/**
* 배경 스와치를 반환한다.
* @param {string} background - 배경 키
* @returns {string} CSS 배경
*/
const getBackgroundSwatch = (background) => backgroundSwatches[background] || 'rgba(100,116,139,0.28)'
/**
* 인용 배경 라벨을 반환한다.
* @param {string} background - 배경 키
* @returns {string} 배경 라벨
*/
const getQuoteBackgroundLabel = (background) => QUOTE_BACKGROUND_LABELS[background] || background
/**
* 인용 배경 스와치를 반환한다.
* @param {string} background - 배경 키
* @returns {string} CSS 배경
*/
const getQuoteBackgroundSwatch = (background) => QUOTE_BACKGROUND_SWATCHES[background] || QUOTE_BACKGROUND_SWATCHES.gray
/**
* 인라인 색상 스와치를 반환한다.
* @param {string} color - 색상 키
* @returns {string} CSS 색상
*/
const getInlineColorSwatch = (color) => inlineColorSwatches[color] || inlineColorSwatches.gray
/**
* 블록 종류 라벨
* @returns {string}
*/
const panelTitle = computed(() => {
if (!props.panel) {
return ''
}
if (props.panel.kind === 'gallery') {
return '갤러리'
}
if (props.panel.kind === 'inline-color') {
return '텍스트 색상'
}
if (props.panel.kind === 'html') {
return 'HTML'
}
if (props.panel.kind === 'link') {
return '링크'
}
if (props.panel.kind === 'iframe') {
return 'iframe'
}
if (props.panel.kind === 'quote') {
return '인용'
}
if (props.panel.kind === 'callout') {
return '콜아웃'
}
if (props.panel.kind === 'code') {
return '코드 블록'
}
if (props.panel.kind === 'toggle') {
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 === 'inline-color') {
return '선택한 텍스트'
}
if (props.panel.kind === 'html') {
return '사용자 HTML'
}
if (props.panel.kind === 'link') {
return 'URL 카드'
}
if (props.panel.kind === 'iframe') {
return 'sandbox iframe'
}
if (props.panel.kind === 'quote') {
return '인용 배경색'
}
if (props.panel.kind === 'callout') {
return '제목·아이콘·배경색'
}
if (props.panel.kind === 'code') {
return '언어·줄번호'
}
if (props.panel.kind === 'toggle') {
return '기본 펼침 상태'
}
return '커서가 위치한 이미지 줄'
})
/**
* 이미지 너비 입력 값을 화면용 문자열로 변환한다.
* @param {number} imageIndex - 이미지 인덱스
* @param {Object} image - 이미지 정보
* @returns {string} 입력 표시 값
*/
const getImagePixelWidthInputValue = (imageIndex, image) => {
const draftKey = getImageWidthStateKey(imageIndex, image)
if (Object.prototype.hasOwnProperty.call(imageWidthDraftValues.value, draftKey)) {
return imageWidthDraftValues.value[draftKey]
}
const width = Math.round(Number(image?.pixelWidth) || 0)
return width ? String(width) : ''
}
/**
* 이미지 너비 UI 상태 키를 반환한다.
* @param {number} imageIndex - 이미지 인덱스
* @param {Object} image - 이미지 정보
* @returns {string} 상태 키
*/
const getImageWidthStateKey = (imageIndex, image) => `${imageIndex}:${String(image?.url || '')}`
/**
* 이미지 너비 직접 입력 UI 상태를 저장한다.
* @param {number} imageIndex - 이미지 인덱스
* @param {Object} image - 이미지 정보
* @param {boolean} enabled - 직접 입력 여부
* @returns {void}
*/
const setImageWidthCustomState = (imageIndex, image, enabled) => {
const nextKeys = new Set(customImageWidthKeys.value)
const key = getImageWidthStateKey(imageIndex, image)
if (enabled) {
nextKeys.add(key)
} else {
nextKeys.delete(key)
}
customImageWidthKeys.value = nextKeys
}
/**
* 이미지 너비 입력 임시 값을 저장한다.
* @param {number} imageIndex - 이미지 인덱스
* @param {Object} image - 이미지 정보
* @param {string} value - 입력 문자열
* @returns {void}
*/
const setImageWidthDraftValue = (imageIndex, image, value) => {
imageWidthDraftValues.value = {
...imageWidthDraftValues.value,
[getImageWidthStateKey(imageIndex, image)]: value
}
}
/**
* 이미지 너비 입력 임시 값을 지운다.
* @param {number} imageIndex - 이미지 인덱스
* @param {Object} image - 이미지 정보
* @returns {void}
*/
const clearImageWidthDraftValue = (imageIndex, image) => {
const key = getImageWidthStateKey(imageIndex, image)
const nextValues = { ...imageWidthDraftValues.value }
delete nextValues[key]
imageWidthDraftValues.value = nextValues
}
/**
* 직접 입력 너비를 허용 범위로 정리한다.
* @param {unknown} value - 입력 너비
* @returns {number|null} 정리된 너비
*/
const normalizeImageWidthInput = (value) => {
const width = Math.round(Number(value) || 0)
if (!width) {
return null
}
return Math.min(Math.max(width, IMAGE_WIDTH_MIN), IMAGE_WIDTH_MAX)
}
/**
* 이미지 너비 대표 옵션 값을 반환한다.
* @param {number} imageIndex - 이미지 인덱스
* @param {Object} image - 이미지 정보
* @returns {string} 셀렉트 값
*/
const getImageWidthPresetValue = (imageIndex, image) => {
const width = Math.round(Number(image?.pixelWidth) || 0)
if (customImageWidthKeys.value.has(getImageWidthStateKey(imageIndex, image))) {
return 'custom'
}
if (!width) {
return 'auto'
}
return IMAGE_WIDTH_PRESET_OPTIONS.some((option) => option.pixelWidth === width)
? String(width)
: 'custom'
}
/**
* 이미지 너비 대표 옵션 변경을 전달한다.
* @param {number} imageIndex - 이미지 인덱스
* @param {Object} image - 이미지 정보
* @param {Event} event - 변경 이벤트
* @returns {void}
*/
const emitImageWidthPresetChange = (imageIndex, image, event) => {
const value = String(event.target?.value || 'auto')
const preset = IMAGE_WIDTH_PRESET_OPTIONS.find((option) => option.value === value)
if (value === 'custom') {
setImageWidthCustomState(imageIndex, image, true)
setImageWidthDraftValue(imageIndex, image, String(Math.round(Number(image?.pixelWidth) || 0) || 356))
emit('update-media-image', imageIndex, {
pixelWidth: Math.round(Number(image?.pixelWidth) || 0) || 356
})
return
}
setImageWidthCustomState(imageIndex, image, false)
clearImageWidthDraftValue(imageIndex, image)
emit('update-media-image', imageIndex, {
pixelWidth: preset?.pixelWidth || null
})
}
/**
* 이미지 너비 입력 변경을 전달한다.
* @param {number} imageIndex - 이미지 인덱스
* @param {Object} image - 이미지 정보
* @param {Event} event - 입력 이벤트
* @returns {void}
*/
const emitImagePixelWidthChange = (imageIndex, image, event) => {
const raw = String(event.target?.value || '').trim()
const width = Math.round(Number(raw) || 0)
setImageWidthCustomState(imageIndex, image, true)
setImageWidthDraftValue(imageIndex, image, raw)
if (!raw) {
emit('update-media-image', imageIndex, {
pixelWidth: null
})
return
}
if (width < IMAGE_WIDTH_MIN || width > IMAGE_WIDTH_MAX) {
return
}
emit('update-media-image', imageIndex, {
pixelWidth: width
})
}
/**
* 이미지 너비 입력 종료 시 허용 범위로 정리해 전달한다.
* @param {number} imageIndex - 이미지 인덱스
* @param {Object} image - 이미지 정보
* @param {Event} event - 입력 이벤트
* @returns {void}
*/
const commitImagePixelWidthChange = (imageIndex, image, event) => {
const raw = String(event.target?.value || '').trim()
const pixelWidth = normalizeImageWidthInput(raw)
setImageWidthCustomState(imageIndex, image, true)
clearImageWidthDraftValue(imageIndex, image)
emit('update-media-image', imageIndex, {
pixelWidth
})
}
/**
* 패널 내부 포인터 조작 중에는 포커스 이탈로 패널이 닫히지 않게 유지한다.
* @returns {void}
*/
const markPanelPointerInside = () => {
window.clearTimeout(panelPointerInsideTimer)
isPanelPointerInside = true
emit('panel-focus-in')
panelPointerInsideTimer = window.setTimeout(() => {
isPanelPointerInside = false
}, 300)
}
/**
* 포커스가 패널 밖으로 나갔을 때만 이탈 이벤트를 보낸다.
* @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
}
if (isPanelPointerInside) {
return
}
emit('panel-focus-out')
}
onBeforeUnmount(() => {
window.clearTimeout(panelPointerInsideTimer)
})
</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"
@pointerdown.capture="markPanelPointerInside"
@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 === 'inline-color'">
<div class="admin-editor-block-panel__inline-color-settings grid gap-4">
<div>
<p class="text-sm font-semibold text-[#394047]">
글자색
</p>
<p class="mt-1 text-xs leading-relaxed text-[#8e9cac]">
선택한 텍스트 범위에 인라인 색상을 적용합니다.
</p>
</div>
<div class="grid grid-cols-2 gap-2">
<button
v-for="color in QUOTE_BACKGROUND_OPTIONS"
:key="`inline-color-${color}`"
class="flex items-center gap-2 rounded border border-[#dce0e5] bg-[#fafafa] px-3 py-2 text-left text-xs font-semibold text-[#657080] transition hover:bg-white"
type="button"
@click="emit('update-inline-color', color)"
>
<span
class="size-5 shrink-0 rounded-full border border-black/5"
:style="{ background: getInlineColorSwatch(color) }"
aria-hidden="true"
/>
<span>{{ getQuoteBackgroundLabel(color) }}</span>
</button>
</div>
</div>
</template>
<template v-else-if="['html', 'link', 'iframe'].includes(panel.kind)">
<label class="admin-editor-block-panel__field grid gap-2 text-sm">
<span class="font-semibold text-[#394047]">
{{ panel.kind === 'link' ? 'URL' : panel.kind === 'iframe' ? 'iframe 코드' : 'HTML 코드' }}
</span>
<textarea
class="min-h-28 resize-y rounded border border-[#d7dde2] bg-[#eff1f2] px-3 py-2 font-mono text-xs leading-5 text-[#15171a] outline-none transition-colors focus:border-[#8e9cac]"
:value="panel.url"
:placeholder="panel.kind === 'link' ? 'https://example.com' : panel.kind === 'iframe' ? '<iframe src=&quot;https://...&quot;></iframe>' : '<table>...</table>'"
@input="emit('update-embed-url', $event.target.value)"
/>
</label>
<p v-if="panel.kind === 'html'" class="admin-editor-block-panel__hint mt-3 text-xs leading-relaxed text-[#8e9cac]">
본문 HTML은 그대로 표시하되 script, iframe, 이벤트 속성 같은 실행성 코드는 제거합니다. iframe은 /iframe 블록을 사용합니다.
</p>
<p v-else-if="panel.kind === 'link'" class="admin-editor-block-panel__hint mt-3 text-xs leading-relaxed text-[#8e9cac]">
YouTube·X(트위터)·Mastodon URL은 임베드로, 그 외 외부 URL은 링크 카드로 표시합니다.
</p>
<p v-else class="admin-editor-block-panel__hint mt-3 text-xs leading-relaxed text-[#8e9cac]">
YouTube·YouTube Shorts, X(트위터) 게시물 URL과 외부 서비스 iframe 코드를 지원합니다. iframe은 src 등 허용된 속성만 사용합니다.
</p>
</template>
<template v-else-if="panel.kind === 'quote'">
<div class="admin-editor-block-panel__quote-settings grid gap-4">
<div>
<p class="text-sm font-semibold text-[#394047]">
배경색
</p>
<p class="mt-1 text-xs leading-relaxed text-[#8e9cac]">
현재 인용 블록의 첫 줄에 배경 옵션을 추가합니다.
</p>
</div>
<div class="grid grid-cols-2 gap-2">
<button
v-for="background in QUOTE_BACKGROUND_OPTIONS"
:key="`quote-background-${background}`"
class="flex items-center gap-2 rounded border px-3 py-2 text-left text-xs font-semibold transition"
:class="panel.quoteBackground === background ? 'border-[#15171a] bg-white text-[#15171a]' : 'border-[#dce0e5] bg-[#fafafa] text-[#657080] hover:bg-white'"
type="button"
@click="emit('update-quote-background', background)"
>
<span
class="size-5 shrink-0 rounded-full border border-black/5"
:style="{ background: getQuoteBackgroundSwatch(background) }"
aria-hidden="true"
/>
<span>{{ getQuoteBackgroundLabel(background) }}</span>
</button>
</div>
</div>
</template>
<template v-else-if="panel.kind === 'callout'">
<div class="admin-editor-block-panel__callout-settings grid gap-5">
<label class="admin-editor-block-panel__field grid gap-2 text-sm">
<span class="font-semibold text-[#394047]">제목</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.title"
type="text"
placeholder="주의사항"
@input="emit('update-callout-options', { title: $event.target.value })"
>
</label>
<label class="admin-editor-block-panel__callout-icon-toggle flex cursor-pointer items-center justify-between gap-4 border-t border-[#e3e6e8] pt-5 text-sm">
<span class="admin-editor-block-panel__callout-icon-toggle-copy flex min-w-0 items-center gap-3">
<span class="admin-editor-block-panel__callout-icon-toggle-label font-bold text-[#15171a]">
아이콘 표시
</span>
</span>
<span class="admin-editor-block-panel__callout-icon-toggle-control relative inline-flex h-7 w-12 shrink-0 items-center">
<input
class="peer sr-only"
type="checkbox"
aria-label="아이콘 표시"
:checked="panel.calloutEmojiEnabled"
@change="emit('update-callout-options', { calloutEmojiEnabled: $event.target.checked })"
>
<span class="absolute inset-0 rounded-full bg-[#c8ced3] transition-colors peer-checked:bg-[#15171a]" aria-hidden="true" />
<span class="relative ml-1 size-5 rounded-full bg-white shadow transition-transform peer-checked:translate-x-5" aria-hidden="true" />
</span>
</label>
<div
class="admin-editor-block-panel__callout-icon-options grid gap-2 transition-opacity"
:class="panel.calloutEmojiEnabled ? '' : 'opacity-45'"
>
<p class="text-sm font-semibold text-[#394047]">
아이콘
</p>
<input
class="rounded border border-[#d7dde2] bg-[#eff1f2] px-3 py-2 text-sm text-[#15171a] outline-none transition-colors focus:border-[#8e9cac] disabled:cursor-not-allowed"
:value="panel.calloutEmoji"
type="text"
maxlength="4"
placeholder="💡"
:disabled="!panel.calloutEmojiEnabled"
@input="emit('update-callout-options', { calloutEmojiEnabled: true, calloutEmoji: $event.target.value })"
>
<div class="grid grid-cols-5 gap-2">
<button
v-for="emoji in CALLOUT_EMOJI_OPTIONS"
:key="`callout-emoji-${emoji}`"
class="grid h-10 place-items-center rounded border text-lg transition"
:class="panel.calloutEmojiEnabled && panel.calloutEmoji === emoji ? 'border-[#15171a] bg-white' : panel.calloutEmojiEnabled ? 'border-[#dce0e5] bg-[#fafafa] hover:bg-white' : 'cursor-not-allowed border-[#dce0e5] bg-[#f4f5f6] text-[#a4adb8]'"
type="button"
:disabled="!panel.calloutEmojiEnabled"
@mousedown.prevent
@click="emit('update-callout-options', { calloutEmojiEnabled: true, calloutEmoji: emoji })"
>
{{ emoji }}
</button>
</div>
</div>
<div class="grid gap-2">
<p class="text-sm font-semibold text-[#394047]">
배경색
</p>
<div class="grid grid-cols-2 gap-2">
<button
v-for="background in CALLOUT_BACKGROUND_OPTIONS"
:key="`callout-background-${background}`"
class="flex items-center gap-2 rounded border px-3 py-2 text-left text-xs font-semibold transition"
:class="panel.calloutBackground === background ? 'border-[#15171a] bg-white text-[#15171a]' : 'border-[#dce0e5] bg-[#fafafa] text-[#657080] hover:bg-white'"
type="button"
@click="emit('update-callout-options', { calloutBackground: background })"
>
<span
class="size-5 shrink-0 rounded-full border border-black/5"
:style="{ background: getBackgroundSwatch(background) }"
aria-hidden="true"
/>
<span>{{ getBackgroundLabel(background) }}</span>
</button>
</div>
</div>
</div>
</template>
<template v-else-if="panel.kind === 'code'">
<div class="admin-editor-block-panel__code-settings grid gap-5">
<label class="admin-editor-block-panel__field grid gap-2 text-sm">
<span class="font-semibold text-[#394047]">언어</span>
<AdminSelectInput
:model-value="panel.language"
:options="CODE_LANGUAGE_OPTIONS"
label="코드 블록 언어"
root-class="admin-editor-block-panel__code-language-select"
width-class="w-full"
select-class="bg-[#eff1f2] border-[#d7dde2] text-[#15171a]"
@update:model-value="emit('update-code-options', { language: $event })"
/>
</label>
<label class="admin-editor-block-panel__code-line-number-toggle flex cursor-pointer items-center justify-between gap-4 border-t border-[#e3e6e8] pt-5 text-sm">
<span class="admin-editor-block-panel__code-line-number-toggle-copy flex min-w-0 items-center gap-3">
<span class="admin-editor-block-panel__code-line-number-toggle-label font-bold text-[#15171a]">
줄번호 표시
</span>
</span>
<span class="admin-editor-block-panel__code-line-number-toggle-control relative inline-flex h-7 w-12 shrink-0 items-center">
<input
class="peer sr-only"
type="checkbox"
aria-label="줄번호 표시"
:checked="panel.showLineNumbers"
@change="emit('update-code-options', { showLineNumbers: $event.target.checked })"
>
<span class="absolute inset-0 rounded-full bg-[#c8ced3] transition-colors peer-checked:bg-[#15171a]" aria-hidden="true" />
<span class="relative ml-1 size-5 rounded-full bg-white shadow transition-transform peer-checked:translate-x-5" aria-hidden="true" />
</span>
</label>
</div>
</template>
<template v-else-if="panel.kind === 'toggle'">
<div class="admin-editor-block-panel__toggle-settings grid gap-3">
<button
class="flex items-center justify-between rounded border px-3 py-3 text-left text-sm font-semibold transition"
:class="panel.defaultOpen ? 'border-[#15171a] bg-white text-[#15171a]' : 'border-[#dce0e5] bg-[#fafafa] text-[#657080] hover:bg-white'"
type="button"
@click="emit('update-toggle-options', { defaultOpen: true })"
>
<span>기본 펼침</span>
<span v-if="panel.defaultOpen" class="text-xs text-[#15171a]">선택됨</span>
</button>
<button
class="flex items-center justify-between rounded border px-3 py-3 text-left text-sm font-semibold transition"
:class="!panel.defaultOpen ? 'border-[#15171a] bg-white text-[#15171a]' : 'border-[#dce0e5] bg-[#fafafa] text-[#657080] hover:bg-white'"
type="button"
@click="emit('update-toggle-options', { defaultOpen: false })"
>
<span>기본 닫힘</span>
<span v-if="!panel.defaultOpen" class="text-xs text-[#15171a]">선택됨</span>
</button>
</div>
</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"
:class="panel.selectedImageIndex === imageIndex ? 'admin-editor-block-panel__media-row--selected' : ''"
>
<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>
<p v-else class="text-[11px] font-normal text-[#8e9cac]">
캡션을 비우면 이미지 아래에 아무 것도 표시하지 않습니다.
</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>
<label
v-if="panel.kind === 'image'"
class="grid gap-1 text-xs font-semibold text-[#394047]"
>
표시 너비
<span class="relative block">
<select
class="w-full appearance-none rounded border border-[#d7dde2] bg-white px-3 py-2 pr-9 text-sm font-normal text-[#15171a] outline-none transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac]"
:value="getImageWidthPresetValue(imageIndex, image)"
@change="emitImageWidthPresetChange(imageIndex, image, $event)"
>
<option
v-for="option in IMAGE_WIDTH_PRESET_OPTIONS"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
<svg class="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-[#394047]" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
</span>
</label>
<label
v-if="panel.kind === 'image' && getImageWidthPresetValue(imageIndex, image) === 'custom'"
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="getImagePixelWidthInputValue(imageIndex, image)"
type="number"
inputmode="numeric"
:min="IMAGE_WIDTH_MIN"
:max="IMAGE_WIDTH_MAX"
step="10"
placeholder="자동"
@input="emitImagePixelWidthChange(imageIndex, image, $event)"
@blur="commitImagePixelWidthChange(imageIndex, image, $event)"
>
</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>
<style scoped>
.admin-editor-block-panel__media-row--selected {
border-color: #2eb6ea;
box-shadow: 0 0 0 2px rgba(46, 182, 234, 0.18);
}
</style>