Files
sori.studio/components/admin/AdminPostForm.vue
zenn de767095ba v1.10.88 품질·보안 개선과 미디어 카탈로그 database 전환을 반영한다.
세션·로그인 제한, ZIP/업로드 스트리밍, CSP 최소 강제와 Report-Only, Playwright 격리 검증을 추가하고 미디어 목록을 reconcile 후 database 모드로 고정한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 11:05:40 +09:00

2877 lines
108 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import {
isAdminPostDraftPlaceholderSlug,
toAdminPostFormTitle,
toAdminPostStoredTitle
} from '../../lib/admin-post-title.js'
import { normalizeMarkdownContent } from '../../lib/markdown-content-normalizer.js'
import {
getPostBodyPreviewThumbnailUrl,
isPageCoverThumbnailUrl,
isPostBodyThumbnailUrl,
isPostCardThumbnailUrl,
withGeneratedImageCacheVersion
} from '../../lib/post-card-thumbnail-url.js'
import { DEFAULT_POST_TAG_LIMIT, normalizePostTagLimit } from '../../lib/post-tag-limit.js'
const props = defineProps({
initialPost: {
type: Object,
default: () => ({})
},
saving: {
type: Boolean,
default: false
},
/** 초안 서버 자동 저장 중(부모 PUT 진행) */
autoSaving: {
type: Boolean,
default: false
},
canViewPost: {
type: Boolean,
default: false
},
publicUrl: {
type: String,
default: ''
},
deleting: {
type: Boolean,
default: false
},
showDelete: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['submit', 'preview', 'delete', 'autosave'])
const autosaveStoragePrefix = 'SORI_ADMIN_POST_AUTOSAVE'
let draftPersistTimer = null
const slugTouched = ref((() => {
const post = props.initialPost
if (!post?.id) {
return false
}
const status = post.status || 'draft'
if (status !== 'draft') {
return true
}
return Boolean(post.slug) && !isAdminPostDraftPlaceholderSlug(post.slug)
})())
const blockEditor = ref(null)
/** 에디터 블록 설정 패널(게시물 설정 사이드바 오버레이) */
const editorBlockPanel = ref({ open: false, panel: null })
/** 본문 에디터 모드: write | preview */
const editorMode = ref('write')
const mediaItems = ref([])
const isMediaPickerOpen = ref(false)
const mediaPickerTarget = ref('featuredImage')
const isLoadingMedia = ref(false)
const isUploadingFeaturedImage = ref(false)
const isSettingsOpen = ref(true)
const tagInput = ref('')
const tagInputRef = ref(null)
const pendingTagNames = ref({})
const isTagSuggestionsOpen = ref(false)
const activeTagSuggestionIndex = ref(0)
const isTagInputComposing = ref(false)
const isTitleInputComposing = ref(false)
const activeMediaPickerTab = ref('upload')
const selectedMediaPickerUrl = ref('')
const mediaSearchQuery = ref('')
const {
displayValue: mediaSearchInput,
onInput: onMediaSearchInput,
onCompositionEnd: onMediaSearchCompositionEnd
} = useImeSearchInput(mediaSearchQuery, { debounce: 250 })
const mediaPickerPage = ref(1)
const mediaPickerPageSize = 50
const mediaPickerPagination = ref({
page: 1,
pageSize: mediaPickerPageSize,
total: 0,
pageCount: 1
})
const savedPostSnapshot = ref('')
const failedDraftAutosaveSnapshot = ref('')
const slugFieldError = ref('')
const isPublishModalOpen = ref(false)
const isUnpublishModalOpen = ref(false)
const publishStatus = ref('draft')
const publishTiming = ref('now')
const scheduledPublishAt = ref('')
const publishModalExpandedSection = ref(null)
const { data: adminTags } = useFetch('/admin/api/tags', {
default: () => []
})
const { data: siteSettings } = useFetch('/admin/api/settings', {
default: () => ({ postTagLimit: DEFAULT_POST_TAG_LIMIT })
})
const defaultTagColor = '#15171a'
const recentTagSuggestionLimit = 5
const defaultTagSuggestionLimit = 8
const emptyTagSuggestionLimit = 13
/** @type {number} 한국어 본문 예상 읽기 속도(분당 공백 제외 문자 수) */
const READING_CHARS_PER_MINUTE = 600
/**
* 자동 생성 파생 이미지 URL인지 확인한다.
* @param {string|null|undefined} imageUrl - 검사할 이미지 URL
* @returns {boolean} 자동 생성 이미지이면 true
*/
const isGeneratedThumbnailUrl = (imageUrl) => isPostCardThumbnailUrl(imageUrl)
|| isPostBodyThumbnailUrl(imageUrl)
|| isPageCoverThumbnailUrl(imageUrl)
/**
* ISO 날짜를 datetime-local 입력값으로 변환
* @param {string} value - ISO 날짜 문자열
* @returns {string} datetime-local 입력값
*/
function toDateTimeLocalValue(value) {
if (!value) {
return ''
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return ''
}
const offsetDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000)
return offsetDate.toISOString().slice(0, 16)
}
/**
* datetime-local 입력값을 ISO 문자열로 변환
* @param {string} value - datetime-local 입력값
* @returns {string | null} ISO 날짜 문자열
*/
function toIsoDateTime(value) {
if (!value) {
return null
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return null
}
return date.toISOString()
}
/**
* datetime-local 값을 날짜·시간 필드로 분리
* @param {string} value - datetime-local 문자열
* @returns {{ date: string, time: string }}
*/
function splitDateTimeLocal(value) {
if (!value || !value.includes('T')) {
return { date: '', time: '' }
}
const [date, time] = value.split('T')
return { date: date || '', time: (time || '').slice(0, 5) }
}
/**
* 날짜·시간 필드를 datetime-local 문자열로 합침
* @param {string} date - YYYY-MM-DD
* @param {string} time - HH:mm
* @returns {string}
*/
function joinDateTimeLocal(date, time) {
if (!date) {
return ''
}
return `${date}T${time || '00:00'}`
}
const form = reactive({
title: toAdminPostFormTitle(props.initialPost.title),
slug: props.initialPost.slug || '',
excerpt: props.initialPost.excerpt || '',
content: normalizeMarkdownContent(props.initialPost.content),
featuredImage: props.initialPost.featuredImage || '',
showFeaturedImage: Boolean(props.initialPost.showFeaturedImage),
isFeatured: Boolean(props.initialPost.isFeatured),
noindex: props.initialPost.noindex === true,
status: props.initialPost.status || 'draft',
publishedAt: toDateTimeLocalValue(props.initialPost.publishedAt),
tagsText: props.initialPost.tags?.join(', ') || ''
})
/**
* 숫자를 한국어 로케일 문자열로 변환한다.
* @param {number} value - 숫자 값
* @returns {string} 표시 문자열
*/
const formatStatNumber = (value) => Number(value || 0).toLocaleString('ko-KR')
/**
* 통계 계산용 마크다운 텍스트를 정리한다.
* @param {string} value - 마크다운 본문
* @returns {string} 표시 텍스트에 가까운 문자열
*/
const normalizePostStatisticsText = (value) => {
return String(value || '')
.replace(/<!--sori:blank-paragraph-->/g, ' ')
.replace(/^```.*$/gm, ' ')
.replace(/^:::\w*.*$/gm, ' ')
.replace(/^> ?(?:\[![^\]]+\]|\{[^}]+\})$/gm, ' ')
.replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1 ')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/[`*_~>#-]/g, ' ')
}
/**
* 본문 단어 수를 계산한다.
* @param {string} value - 통계용 텍스트
* @returns {number} 단어 수
*/
const countPostWords = (value) => {
const tokens = String(value || '').trim().match(/\S+/g)
return tokens ? tokens.length : 0
}
/**
* 본문 블록 수를 계산한다.
* @param {string} value - 마크다운 본문
* @returns {number} 블록 수
*/
const countPostBlocks = (value) => {
const lines = String(value || '').split('\n')
return lines.filter((line) => {
const trimmed = line.trim()
return trimmed
&& trimmed !== ':::'
&& trimmed !== '```'
&& trimmed !== '<!--sori:blank-paragraph-->'
}).length
}
/**
* 본문 이미지 수를 계산한다.
* @param {string} value - 마크다운 본문
* @returns {number} 이미지 수
*/
const countPostImages = (value) => {
const matches = String(value || '').match(/!\[[^\]]*]\([^)]+\)/g)
return matches ? matches.length : 0
}
/**
* 게시물 작성 통계를 계산한다.
* @returns {{ words: number, characters: number, spaces: number, readingMinutes: number, blocks: number, images: number }} 통계 값
*/
const postWritingStats = computed(() => {
const visibleText = normalizePostStatisticsText(form.content)
const characters = Array.from(visibleText.replace(/\s/g, '')).length
const spaces = (visibleText.match(/\s/g) || []).length
return {
words: countPostWords(visibleText),
characters,
spaces,
readingMinutes: characters > 0 ? Math.max(1, Math.ceil(characters / READING_CHARS_PER_MINUTE)) : 0,
blocks: countPostBlocks(form.content),
images: countPostImages(form.content)
}
})
/**
* 서버에 반영된 게시 형태(툴바·자동 저장·슬러그 자동 연동 분기)
* @param {Object} post - 게시물
* @returns {'draft' | 'publishedLive' | 'scheduled' | 'members' | 'private'}
*/
const getPersistedPublishKind = (post) => {
if (!post?.id) {
return 'draft'
}
const st = post.status || 'draft'
if (st === 'members' || st === 'private') {
return st
}
if (st !== 'published') {
return 'draft'
}
const pa = post.publishedAt
if (pa && new Date(pa) > new Date()) {
return 'scheduled'
}
return 'publishedLive'
}
/** 마지막으로 서버와 맞춘 게시 형태 */
const persistedPublishKind = ref(getPersistedPublishKind(props.initialPost))
/** 서버에 반영된 발행 시각(본문 Update 시 유지) */
const persistedPublishedAtIso = ref(props.initialPost.publishedAt || null)
watch(
() => props.initialPost,
(post) => {
persistedPublishKind.value = getPersistedPublishKind(post)
if (post?.publishedAt) {
persistedPublishedAtIso.value = post.publishedAt
}
},
{ deep: true }
)
const autosaveKey = computed(() => `${autosaveStoragePrefix}:${props.initialPost.id || 'new'}`)
const savedPostSlug = computed(() => toSlug(props.initialPost?.slug || ''))
/**
* 제목에서 파생한 슬러그(초안·미수동 편집 시 저장·표시에 사용)
* @returns {string}
*/
const titleDerivedSlug = computed(() => toSlug(form.title))
/**
* 제목 기반 자동 슬러그 표시 여부(직접 편집 전)
* @returns {boolean}
*/
const isSlugAutoFromTitle = computed(() => !slugTouched.value
&& persistedPublishKind.value === 'draft'
&& Boolean(titleDerivedSlug.value))
/**
* 저장·미리보기에 쓰는 최종 슬러그
* @returns {string}
*/
const effectiveSlug = computed(() => {
if (isSlugAutoFromTitle.value) {
return titleDerivedSlug.value
}
return toSlug(form.slug || form.title) || ''
})
const postUrlHint = computed(() => (effectiveSlug.value ? `/post/${effectiveSlug.value}/` : '/post/'))
const hasUnsavedSlugChange = computed(() => Boolean(
savedPostSlug.value
&& effectiveSlug.value
&& effectiveSlug.value !== savedPostSlug.value
))
/**
* Post URL 입력값(자동 슬러그는 제목에서 연동·연한 스타일)
*/
const slugInputValue = computed({
get: () => (isSlugAutoFromTitle.value ? titleDerivedSlug.value : form.slug),
set: (value) => {
slugTouched.value = true
form.slug = value
slugFieldError.value = ''
}
})
/** 저장된 공개 URL(View Post). 슬러그 미저장 변경 중에는 기존 URL 유지 */
const viewPostUrl = computed(() => {
if (!props.publicUrl) {
return ''
}
if (hasUnsavedSlugChange.value) {
return props.publicUrl
}
return effectiveSlug.value ? `/post/${effectiveSlug.value}` : props.publicUrl
})
/**
* 검색엔진 노출 여부(`noindex` 반전, 기본 노출)
*/
const searchEngineVisible = computed({
get: () => !form.noindex,
set: (value) => {
form.noindex = !value
}
})
/**
* 한글 음절 1자를 영문 표기로 변환
* @param {string} char - 변환할 문자
* @returns {string} 영문 표기
*/
const romanizeHangulSyllable = (char) => {
const syllableCode = char.charCodeAt(0)
const hangulBase = 0xac00
const hangulLast = 0xd7a3
if (syllableCode < hangulBase || syllableCode > hangulLast) {
return char
}
const choseong = ['g', 'kk', 'n', 'd', 'tt', 'r', 'm', 'b', 'pp', 's', 'ss', '', 'j', 'jj', 'ch', 'k', 't', 'p', 'h']
const jungseong = ['a', 'ae', 'ya', 'yae', 'eo', 'e', 'yeo', 'ye', 'o', 'wa', 'wae', 'oe', 'yo', 'u', 'wo', 'we', 'wi', 'yu', 'eu', 'ui', 'i']
const jongseong = ['', 'k', 'k', 'ks', 'n', 'nj', 'nh', 't', 'l', 'lk', 'lm', 'lb', 'ls', 'lt', 'lp', 'lh', 'm', 'p', 'ps', 't', 't', 'ng', 't', 't', 'k', 't', 'p', 'h']
const offset = syllableCode - hangulBase
const choseongIndex = Math.floor(offset / 588)
const jungseongIndex = Math.floor((offset % 588) / 28)
const jongseongIndex = offset % 28
return `${choseong[choseongIndex]}${jungseong[jungseongIndex]}${jongseong[jongseongIndex]}`
}
/**
* 문자열을 URL 슬러그로 변환
* @param {string} value - 원본 문자열
* @returns {string} 슬러그
*/
const toSlug = (value) => value
.normalize('NFC')
.split('')
.map((char) => romanizeHangulSyllable(char))
.join('')
.trim()
.toLowerCase()
.replace(/[^a-z0-9\s_-]/g, '')
.replace(/\s+/g, '-')
.replace(/[-_]+/g, (match) => match.includes('_') ? '_' : '-')
.replace(/^[-_]|[-_]$/g, '')
/**
* 게시물 태그 입력 토큰 정규화(한글 유지, 공백은 하이픈으로)
* @param {string} value - 원본 문자열
* @returns {string} 정규화된 태그 문자열
*/
const normalizeTagToken = (value) => {
const raw = String(value).normalize('NFC').trim().toLowerCase()
if (!raw) {
return ''
}
return raw
.replace(/[\\/]+/g, '-')
.replace(/\s+/g, '-')
.replace(/[^a-z0-9가-힣-]+/g, '')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
}
/**
* 새 태그의 화면 표시 이름을 정리한다.
* @param {string} value - 원본 태그 입력값
* @returns {string} 표시 이름
*/
const normalizeTagDisplayName = (value) => String(value)
.normalize('NFC')
.trim()
.replace(/\s*\/\s*/g, '/')
.replace(/\s+/g, ' ')
/**
* 슬러그 입력 포커스 아웃 시 정규화·자동 모드 복귀
* @returns {void}
*/
const commitSlugInput = () => {
if (!slugTouched.value) {
return
}
form.slug = toSlug(form.slug)
if (!form.slug && persistedPublishKind.value === 'draft' && titleDerivedSlug.value) {
slugTouched.value = false
}
}
/**
* 쉼표 구분 태그 문자열을 토큰 배열로 변환
* @param {string} value - 태그 입력 문자열
* @returns {Array<string>} 태그 토큰 목록
*/
const parseTags = (value) => {
const seen = new Set()
const out = []
for (const part of value.split(',')) {
const tag = normalizeTagToken(part)
if (!tag) {
continue
}
const dedupeKey = tag.toLowerCase()
if (seen.has(dedupeKey)) {
continue
}
seen.add(dedupeKey)
out.push(tag)
}
return out
}
const selectedTags = computed(() => parseTags(form.tagsText))
const selectedTagKeys = computed(() => new Set(selectedTags.value.map((tag) => tag.toLowerCase())))
const postTagLimit = computed(() => normalizePostTagLimit(siteSettings.value?.postTagLimit))
const canAddMoreTags = computed(() => selectedTags.value.length < postTagLimit.value)
const availableAdminTags = computed(() => Array.isArray(adminTags.value) ? adminTags.value : [])
const managedTagOptions = computed(() => availableAdminTags.value.filter((tag) => tag.tagType === 'managed'))
/**
* 태그의 최근 사용 시각을 timestamp로 변환한다.
* @param {Object} tag - 태그 항목
* @returns {number} 최근 사용 timestamp
*/
const getTagLastUsedTime = (tag) => {
const timestamp = Date.parse(tag?.lastUsedAt || '')
return Number.isFinite(timestamp) ? timestamp : 0
}
const recentTagOptions = computed(() => availableAdminTags.value
.filter((tag) => getTagLastUsedTime(tag) > 0)
.sort((left, right) => getTagLastUsedTime(right) - getTagLastUsedTime(left))
.slice(0, recentTagSuggestionLimit))
const recentTagSlugSet = computed(() => new Set(recentTagOptions.value.map((tag) => String(tag.slug || '').toLowerCase())))
/**
* 태그 추천 후보를 선택 가능한 항목으로 정리한다.
* @param {Array<Object>} sourceTags - 원본 태그 목록
* @param {string} keyword - 검색어
* @returns {Array<Object>} 선택 가능한 추천 태그
*/
const filterTagSuggestionSource = (sourceTags, keyword = '') => sourceTags.filter((tag) => {
if (!canAddMoreTags.value) {
return false
}
if (!tag?.slug || selectedTagKeys.value.has(tag.slug.toLowerCase())) {
return false
}
if (!keyword) {
return true
}
return tag.name.toLowerCase().includes(keyword) || tag.slug.toLowerCase().includes(keyword)
})
/**
* 중복 태그를 제거한다.
* @param {Array<Object>} tags - 태그 목록
* @returns {Array<Object>} 중복 제거된 태그 목록
*/
const dedupeTagsBySlug = (tags) => {
const seen = new Set()
const out = []
for (const tag of tags) {
const slugKey = String(tag?.slug || '').toLowerCase()
if (!slugKey || seen.has(slugKey)) {
continue
}
seen.add(slugKey)
out.push(tag)
}
return out
}
const tagSuggestionOptions = computed(() => {
const keyword = normalizeTagToken(tagInput.value)
if (keyword) {
return filterTagSuggestionSource(availableAdminTags.value, keyword).slice(0, defaultTagSuggestionLimit)
}
return dedupeTagsBySlug([
...filterTagSuggestionSource(recentTagOptions.value),
...filterTagSuggestionSource(managedTagOptions.value)
]).slice(0, emptyTagSuggestionLimit)
})
const hasTagSuggestions = computed(() => isTagSuggestionsOpen.value && tagSuggestionOptions.value.length > 0)
/**
* 예약 발행 여부 확인
* @returns {boolean} 예약 발행 여부
*/
const isScheduledPost = () => {
const publishedAt = toIsoDateTime(form.publishedAt)
return form.status === 'published' && Boolean(publishedAt) && new Date(publishedAt) > new Date()
}
/** 초안 편집 UI(예약 발행 제외) */
const isDraftLike = computed(() => form.status === 'draft' && !isScheduledPost())
/** 발행됨(즉시 공개) */
const isPublishedLive = computed(() => form.status === 'published' && !isScheduledPost())
/** 툴바·상태줄에 즉시 발행으로 표시할지(서버 기준이거나 초안 글에서 폼만 발행으로 바뀐 경우) */
const displayPublishedLive = computed(() =>
persistedPublishKind.value === 'publishedLive'
|| (persistedPublishKind.value === 'draft' && isPublishedLive.value))
/** 툴바·상태줄에 예약으로 표시할지 */
const displayScheduled = computed(() =>
persistedPublishKind.value === 'scheduled'
|| (persistedPublishKind.value === 'draft' && isScheduledPost()))
/** 툴바·상태줄에 멤버십 글로 표시할지 */
const displayMembersOnly = computed(() =>
persistedPublishKind.value === 'members'
|| (persistedPublishKind.value === 'draft' && form.status === 'members'))
/** 툴바·상태줄에 비공개 글로 표시할지 */
const displayPrivatePost = computed(() =>
persistedPublishKind.value === 'private'
|| (persistedPublishKind.value === 'draft' && form.status === 'private'))
/**
* 발행 모달에 표시할 게시 상태 요약 문구
* @returns {string} 요약 문구
*/
const publishStatusSummaryLabel = computed(() => {
if (publishStatus.value === 'published') {
return '발행'
}
return '초안'
})
/**
* 발행 모달에 표시할 발행 시점 요약 문구
* @returns {string} 요약 문구
*/
const publishTimingSummaryLabel = computed(() => {
if (publishTiming.value === 'now') {
return '지금 바로'
}
const raw = scheduledPublishAt.value
if (!raw) {
return '예약'
}
const date = new Date(raw)
if (Number.isNaN(date.getTime())) {
return '예약'
}
return new Intl.DateTimeFormat('ko-KR', {
dateStyle: 'medium',
timeStyle: 'short'
}).format(date)
})
/**
* 저장 요청용 발행 시각을 결정한다.
* @param {{ allowNewPublishTimestamp?: boolean }} [options] - 신규 발행 시각 허용 여부
* @returns {string|null} ISO 발행 시각 또는 null
*/
const resolvePayloadPublishedAt = (options = {}) => {
if (form.status !== 'published') {
return null
}
const fromForm = toIsoDateTime(form.publishedAt)
if (fromForm) {
return fromForm
}
if (persistedPublishedAtIso.value) {
return persistedPublishedAtIso.value
}
if (props.initialPost.publishedAt) {
return props.initialPost.publishedAt
}
if (options.allowNewPublishTimestamp) {
return new Date().toISOString()
}
return null
}
/**
* 게시물 입력값 생성
* @param {{ allowNewPublishTimestamp?: boolean }} [options] - 신규 발행 시각 허용 여부
* @returns {Object} 게시물 입력값
*/
const createPostPayload = (options = {}) => {
const publishedAt = resolvePayloadPublishedAt(options)
return {
title: toAdminPostStoredTitle(form.title),
slug: effectiveSlug.value,
excerpt: form.excerpt.trim(),
content: normalizeMarkdownContent(form.content),
featuredImage: form.featuredImage.trim() || null,
showFeaturedImage: Boolean(form.showFeaturedImage),
isFeatured: form.isFeatured,
seoTitle: toAdminPostStoredTitle(form.title),
seoDescription: form.excerpt.trim(),
canonicalUrl: '',
noindex: form.noindex,
ogImage: null,
status: form.status,
publishedAt,
tags: selectedTags.value,
tagNames: Object.fromEntries(selectedTags.value.map((tag) => [tag, getTagDisplayName(tag)]))
}
}
/**
* 현재 게시물 입력값을 문자열로 직렬화한다.
* @param {Object|null} [payload] - 직렬화할 게시물 입력값
* @returns {string} 직렬화된 게시물 입력값
*/
const serializePostPayload = (payload = null) => {
const sourcePayload = payload || createPostPayload()
const { tagNames: _tagNames, ...comparablePayload } = sourcePayload
return JSON.stringify(comparablePayload)
}
const hasUnsavedPostChanges = computed(() => serializePostPayload() !== savedPostSnapshot.value)
const serializedPostFingerprint = computed(() => serializePostPayload())
/** 중복 슬러그처럼 저장을 막아야 하는 필드 오류가 있는지 여부 */
const hasBlockingSlugError = computed(() => Boolean(slugFieldError.value))
const isFailedDraftAutosavePayload = computed(() => Boolean(
failedDraftAutosaveSnapshot.value
&& failedDraftAutosaveSnapshot.value === serializedPostFingerprint.value
))
/**
* 초안 자동 저장을 서버에 보낼 수 있는지(슬러그 유효, 제목은 비어 있으면 서버에서 `(제목 없음)`으로 보정)
* @returns {boolean}
*/
const canPersistDraftPayload = computed(() => Boolean(effectiveSlug.value))
/**
* 서버 자동 저장 대상: 폼이 초안이고, 서버에도 아직 초안으로만 반영된 글(발행·예약 글의 미저장 사이드바 변경은 제외)
* @returns {boolean}
*/
const shouldServerAutosaveDraft = computed(() => {
if (!isDraftLike.value) {
return false
}
return persistedPublishKind.value === 'draft'
})
/**
* 자동 저장할 수 없는 초안과 발행 상태 글의 미저장 이탈을 막는다.
* @returns {boolean}
*/
const isRouteLeaveDirty = computed(() => {
if (!hasUnsavedPostChanges.value) {
return false
}
if (props.saving || props.autoSaving || isFailedDraftAutosavePayload.value || !canPersistDraftPayload.value) {
return true
}
if (['publishedLive', 'scheduled', 'members', 'private'].includes(persistedPublishKind.value)) {
return true
}
return false
})
/**
* 툴바 왼쪽 초안 상태 문구(Draft / Saving… / Draft - Saved)
* @returns {string|null} null이면 다른 상태 UI 사용
*/
const headerDraftStatusText = computed(() => {
if (persistedPublishKind.value !== 'draft') {
return null
}
if (displayPublishedLive.value || displayScheduled.value || displayMembersOnly.value || displayPrivatePost.value) {
return null
}
const persisting = props.saving || props.autoSaving
if (persisting) {
return 'Saving...'
}
if (hasUnsavedPostChanges.value) {
return 'Draft'
}
return props.initialPost?.id ? 'Draft - Saved' : 'Draft'
})
/**
* 예약 발행일 툴팁(영문 한 줄)
* @returns {string}
*/
const scheduledStatusTooltip = computed(() => {
const iso = toIsoDateTime(form.publishedAt)
if (!iso) {
return ''
}
const d = new Date(iso)
if (Number.isNaN(d.getTime())) {
return ''
}
const line = d.toLocaleString('en-GB', {
dateStyle: 'long',
timeStyle: 'short'
})
return `Scheduled to be published on ${line}`
})
/**
* 레거시 로컬 자동 저장 키 제거
* @returns {void}
*/
const clearAutosaveLocal = () => {
if (!import.meta.client) {
return
}
localStorage.removeItem(autosaveKey.value)
}
const {
isUnsavedModalOpen,
stayOnUnsavedPage,
leaveUnsavedPage,
allowNextRouteLeave
} = useAdminUnsavedChangesGuard(isRouteLeaveDirty, {
onLeaveConfirmed: clearAutosaveLocal
})
/**
* 미디어 라이브러리 목록 조회
* @returns {Promise<void>}
*/
const fetchMediaItems = async () => {
isLoadingMedia.value = true
try {
const response = await $fetch('/admin/api/media', {
query: {
paged: '1',
tab: 'library',
page: mediaPickerPage.value,
pageSize: mediaPickerPageSize,
kind: 'image',
search: mediaSearchQuery.value.trim() || undefined
}
})
mediaItems.value = response?.items || []
mediaPickerPagination.value = response?.pagination || {
page: 1,
pageSize: mediaPickerPageSize,
total: mediaItems.value.length,
pageCount: 1
}
mediaPickerPage.value = mediaPickerPagination.value.page || 1
} finally {
isLoadingMedia.value = false
}
}
/**
* 대표 이미지 선택 창 열기
* @returns {Promise<void>}
*/
const openMediaPicker = async (target = 'featuredImage') => {
mediaPickerTarget.value = target
activeMediaPickerTab.value = 'upload'
mediaSearchQuery.value = ''
mediaPickerPage.value = 1
mediaPickerPagination.value = {
page: 1,
pageSize: mediaPickerPageSize,
total: 0,
pageCount: 1
}
selectedMediaPickerUrl.value = isGeneratedThumbnailUrl(form[target]) ? '' : form[target] || ''
isMediaPickerOpen.value = true
}
/**
* 대표 이미지 선택 창 닫기
* @returns {void}
*/
const closeMediaPicker = () => {
if (isUploadingFeaturedImage.value) {
return
}
isMediaPickerOpen.value = false
mediaItems.value = []
mediaSearchQuery.value = ''
mediaPickerPage.value = 1
mediaPickerPagination.value = {
page: 1,
pageSize: mediaPickerPageSize,
total: 0,
pageCount: 1
}
}
/**
* 대표 이미지로 선택 가능한 원본 이미지 목록
* @returns {Array<Object>} 자동 생성 이미지를 제외한 이미지 목록
*/
const featuredImageMediaItems = computed(() => mediaItems.value.filter((item) => (
item?.kind === 'image' && !isGeneratedThumbnailUrl(item.url)
)))
const mediaPickerPageCount = computed(() => Math.max(1, Number(mediaPickerPagination.value.pageCount) || 1))
const mediaPickerRangeLabel = computed(() => {
const total = Number(mediaPickerPagination.value.total) || 0
if (total <= 0) {
return '0개'
}
const page = Math.min(Math.max(Number(mediaPickerPagination.value.page) || mediaPickerPage.value, 1), mediaPickerPageCount.value)
const pageSize = Number(mediaPickerPagination.value.pageSize) || mediaPickerPageSize
const start = ((page - 1) * pageSize) + 1
const end = Math.min(start + pageSize - 1, total)
return `${total}개 중 ${start}-${end}개`
})
const mediaPickerPageItems = computed(() => {
const total = mediaPickerPageCount.value
const current = Math.min(Math.max(Number(mediaPickerPage.value) || 1, 1), total)
const pages = new Set([1, total, current])
for (let page = current - 2; page <= current + 2; page += 1) {
pages.add(page)
}
if (current <= 3) {
pages.add(2)
pages.add(3)
pages.add(4)
}
if (current >= total - 2) {
pages.add(total - 3)
pages.add(total - 2)
pages.add(total - 1)
}
const orderedPages = [...pages].filter((page) => page >= 1 && page <= total).sort((left, right) => left - right)
return orderedPages.reduce((items, page, index) => {
const previousPage = orderedPages[index - 1]
if (previousPage && page - previousPage > 1) {
items.push(`ellipsis-${previousPage}-${page}`)
}
items.push(page)
return items
}, [])
})
/**
* 대표 이미지 선택 모달 페이지를 변경한다.
* @param {number} page - 이동할 페이지
* @returns {Promise<void>}
*/
const setMediaPickerPage = async (page) => {
mediaPickerPage.value = Math.min(Math.max(Number(page) || 1, 1), mediaPickerPageCount.value)
await fetchMediaItems()
}
/**
* 대표 이미지 선택 모달에서 사용할 가벼운 이미지 미리보기 URL을 반환한다.
* 선택 값은 원본 URL을 유지하고, 화면 표시만 파생 이미지를 우선 사용한다.
* @param {Object} item - 미디어 항목
* @returns {string} 미리보기 URL
*/
const getFeaturedMediaPreviewUrl = (item) => {
const url = String(item?.url || '')
if (item?.bodyThumbnailStatus?.hasThumbnail) {
return withGeneratedImageCacheVersion(getPostBodyPreviewThumbnailUrl(url), `${item?.updatedAt || ''}-${item?.size || ''}`)
}
if (item?.thumbnailStatus?.hasThumbnail && item.thumbnailStatus.thumbnailUrl) {
return withGeneratedImageCacheVersion(item.thumbnailStatus.thumbnailUrl, `${item?.updatedAt || ''}-${item?.size || ''}`)
}
return url
}
watch(mediaSearchQuery, async () => {
if (!isMediaPickerOpen.value || activeMediaPickerTab.value !== 'library') {
return
}
mediaPickerPage.value = 1
await fetchMediaItems()
})
watch(activeMediaPickerTab, async (tab) => {
if (!isMediaPickerOpen.value || tab !== 'library') {
return
}
mediaPickerPage.value = 1
await fetchMediaItems()
})
/**
* 대표 이미지 선택 상태 변경
* @param {Object} item - 미디어 항목
* @returns {void}
*/
const selectPickedImage = (item) => {
selectedMediaPickerUrl.value = item.url
}
/**
* 선택한 대표 이미지 적용
* @returns {void}
*/
const applyPickedImage = () => {
if (!selectedMediaPickerUrl.value || isGeneratedThumbnailUrl(selectedMediaPickerUrl.value)) {
return
}
form[mediaPickerTarget.value] = selectedMediaPickerUrl.value
closeMediaPicker()
}
/**
* 대표 이미지 삭제
* @returns {void}
*/
const removeFeaturedImage = () => {
form.featuredImage = ''
}
/**
* 태그 입력값을 배지 목록에 추가
* @returns {void}
*/
const addTagToken = (value) => {
const displayName = normalizeTagDisplayName(value)
const nextTag = normalizeTagToken(value)
if (!nextTag) {
tagInput.value = ''
return
}
if (!selectedTagKeys.value.has(nextTag.toLowerCase()) && !canAddMoreTags.value) {
tagInput.value = ''
isTagSuggestionsOpen.value = false
return
}
const nextTags = [...selectedTags.value]
if (!selectedTagKeys.value.has(nextTag.toLowerCase())) {
nextTags.push(nextTag)
}
const hasExistingTag = availableAdminTags.value.some((tag) => tag.slug === nextTag)
if (!hasExistingTag && displayName) {
pendingTagNames.value = {
...pendingTagNames.value,
[nextTag]: displayName
}
}
form.tagsText = nextTags.join(', ')
tagInput.value = ''
activeTagSuggestionIndex.value = 0
}
/**
* 태그 입력값을 배지 목록에 추가
* @returns {void}
*/
const addTagFromInput = () => {
addTagToken(tagInput.value)
}
/**
* 기존 태그 추천 항목을 배지 목록에 추가
* @param {Object} tag - 태그 항목
* @returns {void}
*/
const selectTagSuggestion = (tag) => {
addTagToken(tag.slug)
isTagSuggestionsOpen.value = false
tagInputRef.value?.focus()
}
/**
* 태그 배지 삭제
* @param {string} tag - 삭제할 태그
* @returns {void}
*/
const removeTag = (tag) => {
form.tagsText = selectedTags.value.filter((item) => item !== tag).join(', ')
}
/**
* 태그 슬러그의 화면 표시 이름을 반환한다.
* @param {string} slug - 태그 슬러그
* @returns {string} 태그 표시 이름
*/
const getTagDisplayName = (slug) => availableAdminTags.value.find((tag) => tag.slug === slug)?.name
|| pendingTagNames.value[slug]
|| slug
/**
* 태그 슬러그의 고유 색상을 반환한다.
* @param {string} slug - 태그 슬러그
* @returns {string} 태그 색상
*/
const getTagColor = (slug) => availableAdminTags.value.find((tag) => tag.slug === slug)?.color || defaultTagColor
/**
* 태그 배지 스타일을 생성한다.
* @param {string} color - 태그 색상
* @returns {Object} 배지 인라인 스타일
*/
const createTagBadgeStyle = (color) => ({
backgroundColor: `color-mix(in srgb, ${color} 14%, white)`,
borderColor: `color-mix(in srgb, ${color} 34%, white)`,
color
})
/**
* 태그 추천 목록의 보조 라벨을 반환한다.
* @param {Object} tag - 태그 항목
* @returns {string} 보조 라벨
*/
const getTagSuggestionMeta = (tag) => {
const slugKey = String(tag?.slug || '').toLowerCase()
const typeLabel = recentTagSlugSet.value.has(slugKey)
? '최근'
: (tag.tagType === 'managed' ? '메인' : '일반')
return tag.name.toLowerCase() === tag.slug.toLowerCase() ? typeLabel : `${typeLabel} · ${tag.slug}`
}
/**
* 태그 추천 목록을 열고 입력에 포커스한다.
* @returns {void}
*/
const openTagSuggestions = () => {
if (!canAddMoreTags.value) {
isTagSuggestionsOpen.value = false
return
}
isTagSuggestionsOpen.value = true
activeTagSuggestionIndex.value = 0
tagInputRef.value?.focus()
}
/**
* 태그 추천 목록을 토글한다.
* @returns {void}
*/
const toggleTagSuggestions = () => {
if (!canAddMoreTags.value) {
isTagSuggestionsOpen.value = false
return
}
if (isTagSuggestionsOpen.value) {
isTagSuggestionsOpen.value = false
return
}
openTagSuggestions()
}
/**
* 태그 입력 포커스 해제 처리
* @returns {void}
*/
const handleTagBlur = () => {
window.setTimeout(() => {
addTagFromInput()
isTagSuggestionsOpen.value = false
}, 80)
}
/**
* 태그 입력 키 처리
* @param {KeyboardEvent} event - 키보드 이벤트
* @returns {void}
*/
const handleTagKeydown = (event) => {
if (event.isComposing || isTagInputComposing.value || event.keyCode === 229) {
return
}
if (event.key === 'ArrowDown' && tagSuggestionOptions.value.length) {
event.preventDefault()
if (!isTagSuggestionsOpen.value) {
isTagSuggestionsOpen.value = true
activeTagSuggestionIndex.value = 0
return
}
isTagSuggestionsOpen.value = true
activeTagSuggestionIndex.value = (activeTagSuggestionIndex.value + 1) % tagSuggestionOptions.value.length
return
}
if (event.key === 'ArrowUp' && tagSuggestionOptions.value.length) {
event.preventDefault()
if (!isTagSuggestionsOpen.value) {
isTagSuggestionsOpen.value = true
activeTagSuggestionIndex.value = tagSuggestionOptions.value.length - 1
return
}
isTagSuggestionsOpen.value = true
activeTagSuggestionIndex.value = activeTagSuggestionIndex.value === 0
? tagSuggestionOptions.value.length - 1
: activeTagSuggestionIndex.value - 1
return
}
if (event.key === 'Escape' && isTagSuggestionsOpen.value) {
event.preventDefault()
isTagSuggestionsOpen.value = false
return
}
if (event.key === 'Enter' || event.key === ',') {
event.preventDefault()
if (event.key === 'Enter' && hasTagSuggestions.value) {
selectTagSuggestion(tagSuggestionOptions.value[activeTagSuggestionIndex.value] || tagSuggestionOptions.value[0])
return
}
addTagFromInput()
return
}
if (event.key === 'Backspace' && !tagInput.value && selectedTags.value.length) {
event.preventDefault()
removeTag(selectedTags.value.at(-1))
}
}
/**
* 대표 이미지 파일 업로드
* @param {File} file - 업로드 파일
* @returns {Promise<void>}
*/
const uploadFeaturedImageFile = async (file) => {
const formData = new FormData()
formData.append('files', file)
isUploadingFeaturedImage.value = true
try {
const result = await $fetch('/admin/api/uploads', {
method: 'POST',
body: formData
})
selectedMediaPickerUrl.value = result.files?.[0]?.url || ''
await fetchMediaItems()
activeMediaPickerTab.value = 'library'
} finally {
isUploadingFeaturedImage.value = false
}
}
/**
* 대표 이미지 파일 입력 처리
* @param {Event} event - 파일 입력 이벤트
* @returns {Promise<void>}
*/
const uploadFeaturedImage = async (event) => {
const files = event.target.files
if (!files?.length) {
return
}
try {
await uploadFeaturedImageFile(files[0])
} finally {
event.target.value = ''
}
}
/**
* 대표 이미지 드롭 업로드
* @param {DragEvent} event - 드롭 이벤트
* @returns {Promise<void>}
*/
const dropFeaturedImage = async (event) => {
const files = event.dataTransfer?.files
if (!files?.length) {
return
}
await uploadFeaturedImageFile(files[0])
}
/**
* 날짜·시간 입력의 네이티브 선택 UI를 연다.
* @param {MouseEvent} event - 클릭 이벤트
* @returns {void}
*/
const openDateTimePicker = (event) => {
const label = event.currentTarget?.closest('label')
if (!label) {
return
}
const input = label.querySelector('input[type="date"], input[type="time"]')
if (!input || input.disabled) {
return
}
event.preventDefault()
if (typeof input.showPicker === 'function') {
try {
input.showPicker()
return
} catch {
// showPicker 미지원·사용자 제스처 제한 시 focus로 대체
}
}
input.focus()
input.click()
}
/**
* 제목 입력 후 본문 에디터로 이동
* @param {KeyboardEvent} event - 키보드 이벤트
* @returns {void}
*/
/**
* 에디터에서 블록 패널 상태를 수신한다.
* @param {{ open: boolean, panel: Object|null }} payload - 패널 상태
* @returns {void}
*/
const onEditorBlockPanel = (payload) => {
editorBlockPanel.value = payload
}
/**
* 블록 패널 포커스 진입
* @returns {void}
*/
const onBlockPanelFocusIn = () => {
blockEditor.value?.handleBlockPanelFocusIn?.()
}
/**
* 블록 패널 포커스 이탈
* @returns {void}
*/
const onBlockPanelFocusOut = () => {
blockEditor.value?.handleBlockPanelFocusOut?.()
}
/**
* 블록 패널에서 미디어 이미지를 수정한다.
* @param {number} imageIndex - 이미지 인덱스
* @param {Object} patch - 변경 값
* @returns {void}
*/
const onBlockPanelUpdateMediaImage = (imageIndex, patch) => {
blockEditor.value?.updateActiveMediaImage?.(imageIndex, patch)
}
/**
* 블록 패널에서 대체 텍스트 사용 여부를 바꾼다.
* @param {number} imageIndex - 이미지 인덱스
* @param {boolean} enabled - 사용 여부
* @returns {void}
*/
const onBlockPanelSetMediaUseAlt = (imageIndex, enabled) => {
blockEditor.value?.setActiveMediaUseAlt?.(imageIndex, enabled)
}
/**
* 블록 패널에서 갤러리 이미지 순서를 바꾼다.
* @param {number} imageIndex - 이미지 인덱스
* @param {-1|1} direction - 이동 방향
* @returns {void}
*/
const onBlockPanelMoveGalleryImage = (imageIndex, direction) => {
blockEditor.value?.moveActiveGalleryImage?.(imageIndex, direction)
}
/**
* 블록 패널에서 미디어 이미지를 삭제한다.
* @param {number} imageIndex - 이미지 인덱스
* @returns {void}
*/
const onBlockPanelRemoveMediaImage = (imageIndex) => {
blockEditor.value?.removeActiveMediaImage?.(imageIndex)
}
/**
* 블록 패널에서 갤러리에 이미지를 추가한다.
* @returns {void}
*/
const onBlockPanelAddGalleryImages = () => {
blockEditor.value?.openMediaPicker?.('active-gallery')
}
/**
* 블록 패널에서 HTML 임베드 값을 수정한다.
* @param {string} url - HTML 임베드 값
* @returns {void}
*/
const onBlockPanelUpdateEmbedUrl = (url) => {
blockEditor.value?.updateActiveEmbedUrl?.(url)
}
/**
* 블록 패널에서 인용 배경색을 수정한다.
* @param {string} background - 인용 배경색
* @returns {void}
*/
const onBlockPanelUpdateQuoteBackground = (background) => {
blockEditor.value?.updateActiveQuoteBackground?.(background)
}
/**
* 블록 패널에서 콜아웃 옵션을 수정한다.
* @param {Object} patch - 콜아웃 변경 값
* @returns {void}
*/
const onBlockPanelUpdateCalloutOptions = (patch) => {
blockEditor.value?.updateActiveCalloutOptions?.(patch)
}
/**
* 블록 패널에서 코드 블록 옵션을 수정한다.
* @param {Object} patch - 코드 블록 변경 값
* @returns {void}
*/
const onBlockPanelUpdateCodeOptions = (patch) => {
blockEditor.value?.updateActiveCodeOptions?.(patch)
}
/**
* 블록 패널에서 토글 옵션을 수정한다.
* @param {Object} patch - 토글 변경 값
* @returns {void}
*/
const onBlockPanelUpdateToggleOptions = (patch) => {
blockEditor.value?.updateActiveToggleOptions?.(patch)
}
/**
* 블록 패널에서 선택 텍스트 색상을 수정한다.
* @param {string} color - 인라인 색상
* @returns {void}
*/
const onBlockPanelUpdateInlineColor = (color) => {
blockEditor.value?.updateActiveInlineColor?.(color)
}
const focusContentEditor = (event) => {
if (event?.isComposing || isTitleInputComposing.value || event?.keyCode === 229) {
return
}
event?.preventDefault()
blockEditor.value?.focusFirstBlock()
}
/**
* 게시물 입력값 제출
* @returns {void}
*/
const submitPost = () => {
if (hasBlockingSlugError.value) {
return
}
isPublishModalOpen.value = false
isUnpublishModalOpen.value = false
emit('submit', createPostPayload({ allowNewPublishTimestamp: true }))
}
/**
* 발행·예약 글 본문 수정분을 서버에 반영(Update)
* @returns {void}
*/
const requestToolbarUpdate = () => {
if (hasBlockingSlugError.value) {
return
}
emit('submit', createPostPayload())
}
/**
* 발행 취소·예약 취소 확인 화면을 연다.
* @returns {void}
*/
const openUnpublishModal = () => {
isUnpublishModalOpen.value = true
}
/**
* 발행 취소·예약 취소 확인 화면을 닫는다.
* @returns {void}
*/
const closeUnpublishModal = () => {
isUnpublishModalOpen.value = false
}
/**
* 발행 취소 또는 예약 취소(초안으로 되돌림)를 확정한다.
* @returns {void}
*/
const confirmUnpublishOrUnschedule = () => {
form.status = 'draft'
form.publishedAt = ''
submitPost()
}
/**
* 게시물 미리보기 요청
* @returns {void}
*/
const previewPost = () => {
emit('preview', createPostPayload())
}
/**
* 게시물 삭제 요청
* @returns {void}
*/
const deletePost = () => {
emit('delete')
}
/**
* 설정 패널 표시 상태 전환
* @returns {void}
*/
const toggleSettingsPanel = () => {
isSettingsOpen.value = !isSettingsOpen.value
}
/**
* 발행 모달을 현재 폼 상태로 초기화한다.
* @returns {void}
*/
const syncPublishModalStateFromForm = () => {
if (persistedPublishKind.value === 'draft') {
publishStatus.value = 'published'
publishTiming.value = 'now'
scheduledPublishAt.value = toDateTimeLocalValue(new Date().toISOString())
return
}
publishStatus.value = form.status === 'published' ? 'published' : 'draft'
scheduledPublishAt.value = form.publishedAt || toDateTimeLocalValue(new Date(Date.now() + 3600000).toISOString())
publishTiming.value = isScheduledPost() ? 'schedule' : 'now'
}
/**
* 발행 모달 열기
* @returns {void}
*/
const openPublishModal = () => {
if (hasBlockingSlugError.value) {
return
}
syncPublishModalStateFromForm()
publishModalExpandedSection.value = null
isPublishModalOpen.value = true
}
/**
* 발행 모달 닫기
* @returns {void}
*/
const closePublishModal = () => {
isPublishModalOpen.value = false
}
/**
* 발행 모달에서 선택한 값을 폼에 반영
* @returns {void}
*/
const applyPublishSelectionToForm = () => {
form.status = publishStatus.value
if (publishStatus.value !== 'published') {
form.publishedAt = ''
return
}
if (publishTiming.value === 'schedule') {
form.publishedAt = scheduledPublishAt.value || toDateTimeLocalValue(new Date(Date.now() + 3600000).toISOString())
return
}
form.publishedAt = toDateTimeLocalValue(new Date().toISOString())
}
/**
* 발행 모달에서 최종 저장/발행 확정
* @returns {void}
*/
const submitFromPublishModal = () => {
if (hasBlockingSlugError.value) {
return
}
applyPublishSelectionToForm()
submitPost()
}
/**
* 발행 모달에서 설정 행 펼침을 토글한다.
* @param {'status' | 'timing'} section - 펼칠 행
* @returns {void}
*/
const togglePublishModalSection = (section) => {
publishModalExpandedSection.value =
publishModalExpandedSection.value === section ? null : section
}
/**
* 발행 모달에서 게시 상태를 선택한다.
* @param {'published' | 'draft'} status - 선택 상태
* @returns {void}
*/
const selectPublishStatus = (status) => {
publishStatus.value = status
publishModalExpandedSection.value = null
if (status !== 'published') {
publishTiming.value = 'now'
}
}
/**
* 발행 모달에서 발행 시점을 선택한다.
* @param {'now' | 'schedule'} timing - 즉시 또는 예약
* @returns {void}
*/
const selectPublishTiming = (timing) => {
publishTiming.value = timing
if (timing === 'now') {
publishModalExpandedSection.value = null
} else if (!scheduledPublishAt.value) {
scheduledPublishAt.value = toDateTimeLocalValue(new Date(Date.now() + 3600000).toISOString())
}
}
/** 설정 패널 발행 날짜(YYYY-MM-DD) */
const settingsPublishDate = computed({
get: () => splitDateTimeLocal(form.publishedAt).date,
set: (date) => {
const { time } = splitDateTimeLocal(form.publishedAt)
form.publishedAt = joinDateTimeLocal(date, time || '12:00')
}
})
/** 설정 패널 발행 시각(HH:mm) */
const settingsPublishTime = computed({
get: () => splitDateTimeLocal(form.publishedAt).time,
set: (time) => {
const { date } = splitDateTimeLocal(form.publishedAt)
const baseDate = date || new Date().toISOString().slice(0, 10)
form.publishedAt = joinDateTimeLocal(baseDate, time || '12:00')
}
})
/** 발행 모달 예약 날짜 */
const modalScheduleDate = computed({
get: () => splitDateTimeLocal(scheduledPublishAt.value).date,
set: (date) => {
const { time } = splitDateTimeLocal(scheduledPublishAt.value)
scheduledPublishAt.value = joinDateTimeLocal(date, time || '12:00')
}
})
/** 발행 모달 예약 시각 */
const modalScheduleTime = computed({
get: () => splitDateTimeLocal(scheduledPublishAt.value).time,
set: (time) => {
const { date } = splitDateTimeLocal(scheduledPublishAt.value)
const baseDate = date || new Date().toISOString().slice(0, 10)
scheduledPublishAt.value = joinDateTimeLocal(baseDate, time || '12:00')
}
})
/** 발행 취소 화면에 표시할 서버 반영 발행 시각 문구 */
const unpublishPersistedPublishedLabel = computed(() => {
const iso = props.initialPost?.publishedAt
if (!iso) {
return ''
}
const d = new Date(iso)
if (Number.isNaN(d.getTime())) {
return ''
}
return new Intl.DateTimeFormat('ko-KR', {
dateStyle: 'long',
timeStyle: 'short'
}).format(d)
})
/** 발행 취소 화면 제목(즉시 발행 / 예약) */
const unpublishModalKind = computed(() => (
persistedPublishKind.value === 'scheduled' ? 'scheduled' : 'published'
))
/**
* 현재 입력값을 저장 완료 기준점으로 표시한다.
* @param {Object|null} [savedPayload] - 실제 서버에 저장된 입력값
* @returns {void}
*/
const markSaved = (savedPayload = null) => {
savedPostSnapshot.value = serializePostPayload(savedPayload)
failedDraftAutosaveSnapshot.value = ''
slugFieldError.value = ''
const publishedAt = resolvePayloadPublishedAt()
if (publishedAt) {
persistedPublishedAtIso.value = publishedAt
}
}
/**
* 실패한 초안 자동 저장 입력값을 기록해 같은 요청의 반복 재시도를 막는다.
* @param {Object|null} [failedPayload] - 실패한 자동 저장 입력값
* @returns {void}
*/
const markAutosaveFailed = (failedPayload = null) => {
failedDraftAutosaveSnapshot.value = serializePostPayload(failedPayload)
}
/**
* 슬러그 입력 필드 아래에 표시할 오류 문구를 설정한다.
* @param {string} message - 오류 문구
* @returns {void}
*/
const markSlugError = (message) => {
slugFieldError.value = message
}
/**
* 디바운스 타이머를 취소하고, 즉시 서버로 보낼 초안 페이로드가 있으면 반환한다(라우트 이탈 플러시용)
* @returns {Object|null} POST/PUT 본문 또는 null
*/
const takePendingAutosavePayload = () => {
if (!import.meta.client) {
return null
}
window.clearTimeout(draftPersistTimer)
draftPersistTimer = null
if (props.saving || props.autoSaving) {
return null
}
if (!shouldServerAutosaveDraft.value) {
return null
}
if (!hasUnsavedPostChanges.value) {
return null
}
if (!canPersistDraftPayload.value) {
return null
}
if (isFailedDraftAutosavePayload.value) {
return null
}
return createPostPayload()
}
watch(publishStatus, (next) => {
if (next !== 'published') {
publishTiming.value = 'now'
if (publishModalExpandedSection.value === 'timing') {
publishModalExpandedSection.value = null
}
}
})
watch(() => form.status, (next) => {
if (next === 'published' && !toIsoDateTime(form.publishedAt)) {
form.publishedAt = toDateTimeLocalValue(new Date().toISOString())
}
})
watch(serializedPostFingerprint, () => {
if (!import.meta.client) {
return
}
if (props.saving || props.autoSaving) {
return
}
if (!shouldServerAutosaveDraft.value) {
return
}
if (!hasUnsavedPostChanges.value) {
return
}
if (!canPersistDraftPayload.value) {
return
}
if (isFailedDraftAutosavePayload.value) {
return
}
window.clearTimeout(draftPersistTimer)
draftPersistTimer = window.setTimeout(() => {
if (props.saving || props.autoSaving) {
return
}
if (!shouldServerAutosaveDraft.value || !hasUnsavedPostChanges.value || !canPersistDraftPayload.value || isFailedDraftAutosavePayload.value) {
return
}
emit('autosave', createPostPayload())
}, 1200)
})
watch(() => [props.saving, props.autoSaving], ([saving, autoSaving]) => {
if (!import.meta.client || saving || autoSaving) {
return
}
if (!shouldServerAutosaveDraft.value || !hasUnsavedPostChanges.value || !canPersistDraftPayload.value) {
return
}
if (isFailedDraftAutosavePayload.value) {
return
}
window.clearTimeout(draftPersistTimer)
draftPersistTimer = window.setTimeout(() => {
if (props.saving || props.autoSaving) {
return
}
if (!shouldServerAutosaveDraft.value || !hasUnsavedPostChanges.value || !canPersistDraftPayload.value || isFailedDraftAutosavePayload.value) {
return
}
emit('autosave', createPostPayload())
}, 250)
})
onMounted(() => {
if (!props.initialPost?.id && !String(form.slug || '').trim()) {
const suffix = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID().replace(/-/g, '').slice(0, 24)
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 14)}`.slice(0, 24)
form.slug = `d${suffix}`
slugTouched.value = false
}
markSaved()
clearAutosaveLocal()
})
onBeforeUnmount(() => {
if (import.meta.client) {
window.clearTimeout(draftPersistTimer)
}
})
defineExpose({
clearAutosave: clearAutosaveLocal,
createPostPayload,
markAutosaveFailed,
markSlugError,
markSaved,
allowNextRouteLeave,
takePendingAutosavePayload
})
</script>
<template>
<form class="admin-post-form flex h-screen min-h-screen overflow-hidden bg-white" @submit.prevent>
<div class="admin-post-form__workspace flex min-w-0 flex-1 flex-col bg-white">
<header class="admin-post-form__toolbar flex h-[56px] shrink-0 items-center bg-white px-8">
<div class="admin-post-form__toolbar-inner flex h-[34px] min-w-0 flex-1 items-center justify-between">
<div class="admin-post-form__toolbar-left flex h-full min-w-0 flex-1 items-center gap-3">
<NuxtLink class="admin-post-form__toolbar-link inline-flex shrink-0 items-center gap-2 rounded px-2 py-1.5 text-sm font-medium text-[#394047] transition-colors hover:bg-[#f1f3f4] hover:text-black" to="/admin/posts">
<span class="admin-post-form__toolbar-back text-lg leading-none" aria-hidden="true">&lt;</span>
<span>Posts</span>
</NuxtLink>
<div class="admin-post-form__toolbar-status-row flex min-w-0 flex-1 items-center gap-2">
<span
v-if="displayPublishedLive"
class="admin-post-form__toolbar-status-published truncate rounded px-2 py-1.5 text-sm font-medium text-[#8E9CAC]"
>
Published
</span>
<span
v-else-if="displayScheduled"
:title="scheduledStatusTooltip"
class="admin-post-form__toolbar-status-scheduled cursor-default truncate rounded px-2 py-1.5 text-sm font-normal text-[#2BBA3C]"
>
Scheduled
</span>
<span
v-else-if="displayMembersOnly"
class="admin-post-form__toolbar-status-members truncate rounded px-2 py-1.5 text-sm font-medium text-[#5a63d8]"
>
Members
</span>
<span
v-else-if="displayPrivatePost"
class="admin-post-form__toolbar-status-private truncate rounded px-2 py-1.5 text-sm font-medium text-[#fb2d8d]"
>
Private
</span>
<span
v-else-if="headerDraftStatusText"
class="admin-post-form__toolbar-status-draft truncate rounded px-2 py-1.5 text-sm text-[#8E9CAC]"
>
{{ headerDraftStatusText }}
</span>
</div>
</div>
<div class="admin-post-form__toolbar-actions flex h-full shrink-0 items-center gap-2">
<div
id="admin-post-form-mode-toggle-host"
class="admin-post-form__mode-toggle-host flex shrink-0 items-center"
/>
<template v-if="persistedPublishKind === 'draft'">
<template v-if="isDraftLike">
<button
class="admin-post-form__toolbar-publish rounded px-3 py-1.5 text-sm font-bold transition-colors disabled:cursor-default disabled:text-[#8E9CAC] disabled:hover:bg-transparent enabled:text-[#2BBA3C] enabled:hover:bg-[#f1f3f4]"
type="button"
:disabled="hasBlockingSlugError || saving || autoSaving"
@click="openPublishModal"
>
Publish
</button>
</template>
<button
v-else
class="admin-post-form__toolbar-update rounded px-3 py-1.5 text-sm font-bold transition-colors disabled:cursor-default disabled:text-[#8E9CAC] disabled:hover:bg-transparent enabled:text-[#394047] enabled:hover:bg-[#f1f3f4]"
type="button"
:disabled="!hasUnsavedPostChanges || hasBlockingSlugError || saving || autoSaving"
@click="requestToolbarUpdate"
>
Update
</button>
</template>
<template v-else-if="persistedPublishKind === 'publishedLive'">
<button
class="admin-post-form__toolbar-update rounded px-3 py-1.5 text-sm font-bold transition-colors disabled:cursor-default disabled:text-[#8E9CAC] disabled:hover:bg-transparent enabled:text-[#394047] enabled:hover:bg-[#f1f3f4]"
type="button"
:disabled="!hasUnsavedPostChanges || hasBlockingSlugError || saving || autoSaving"
@click="requestToolbarUpdate"
>
Update
</button>
<button
class="admin-post-form__toolbar-unpublish rounded px-3 py-1.5 text-sm font-semibold text-[#394047] transition-colors hover:bg-[#f1f3f4]"
type="button"
:disabled="saving"
@click="openUnpublishModal"
>
Unpublish
</button>
</template>
<template v-else-if="persistedPublishKind === 'scheduled'">
<button
class="admin-post-form__toolbar-update rounded px-3 py-1.5 text-sm font-bold transition-colors disabled:cursor-default disabled:text-[#8E9CAC] disabled:hover:bg-transparent enabled:text-[#394047] enabled:hover:bg-[#f1f3f4]"
type="button"
:disabled="!hasUnsavedPostChanges || hasBlockingSlugError || saving || autoSaving"
@click="requestToolbarUpdate"
>
Update
</button>
<button
class="admin-post-form__toolbar-unschedule rounded px-3 py-1.5 text-sm font-semibold text-[#394047] transition-colors hover:bg-[#f1f3f4]"
type="button"
:disabled="saving"
@click="openUnpublishModal"
>
Unschedule
</button>
</template>
<template v-else-if="persistedPublishKind === 'members' || persistedPublishKind === 'private'">
<button
class="admin-post-form__toolbar-update rounded px-3 py-1.5 text-sm font-bold transition-colors disabled:cursor-default disabled:text-[#8E9CAC] disabled:hover:bg-transparent enabled:text-[#394047] enabled:hover:bg-[#f1f3f4]"
type="button"
:disabled="!hasUnsavedPostChanges || hasBlockingSlugError || saving || autoSaving"
@click="requestToolbarUpdate"
>
Update
</button>
</template>
<button
class="admin-post-form__settings-toggle grid size-[34px] place-items-center rounded text-[#394047] transition-colors hover:bg-[#f1f3f4] hover:text-black"
type="button"
:aria-pressed="isSettingsOpen"
aria-label="게시물 설정 패널 전환"
@click="toggleSettingsPanel"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
<path d="M14 2.16699C14.3242 2.16699 14.4998 2.39365 14.5 2.57129V13.4287C14.5 13.606 14.3242 13.834 14 13.834H11.5V2.16699H14ZM2 2.16699H10.5V13.834H2C1.6756 13.834 1.5 13.6064 1.5 13.4287V2.57129C1.50024 2.39409 1.67607 2.16699 2 2.16699Z" stroke="currentColor" />
</svg>
</button>
</div>
</div>
</header>
<main class="admin-post-form__editor-scroll min-h-0 flex-1 overflow-y-auto">
<section class="admin-post-form__content mx-auto w-full max-w-[804px] px-8 pb-16 pt-16">
<div class="admin-post-form__feature-block mb-9">
<figure v-if="form.featuredImage" class="admin-post-form__featured-editor group relative overflow-hidden bg-white">
<img class="admin-post-form__featured-editor-image aspect-[16/9] w-full bg-surface object-cover" :src="form.featuredImage" alt="">
<figcaption class="admin-post-form__featured-editor-actions pointer-events-none absolute inset-0 flex items-end justify-end gap-2 bg-gradient-to-t from-black/40 via-black/5 to-transparent p-4 opacity-0 transition-opacity duration-150 group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100">
<button class="admin-post-form__featured-change rounded bg-white/95 px-3 py-1.5 text-xs font-semibold text-[#15171a] shadow-sm transition-colors hover:bg-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white" type="button" @click="openMediaPicker('featuredImage')">
변경
</button>
<button class="admin-post-form__featured-remove rounded bg-[#fff1f2]/95 px-3 py-1.5 text-xs font-semibold text-red-700 shadow-sm transition-colors hover:bg-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white" type="button" @click="removeFeaturedImage">
삭제
</button>
</figcaption>
</figure>
<div v-else class="admin-post-form__feature-empty flex h-6 items-start">
<button class="admin-post-form__feature-add inline-flex items-center gap-1.5 rounded px-2 py-1 text-sm text-[#8e9cac] transition-colors hover:bg-[#eff1f2] hover:text-[#394047]" type="button" @click="openMediaPicker('featuredImage')">
<span aria-hidden="true">+</span>
<span>대표 이미지 추가</span>
</button>
</div>
</div>
<input
v-model="form.title"
class="admin-post-form__title-input mb-2 w-full border-0 bg-transparent px-0 py-0 text-3xl font-bold leading-tight text-ink outline-none placeholder:text-[#8e9cac]"
type="text"
placeholder="제목"
@keydown.enter="focusContentEditor"
@compositionstart="isTitleInputComposing = true"
@compositionend="isTitleInputComposing = false"
>
<div class="admin-post-form__field admin-post-form__content-editor text-sm">
<AdminMarkdownEditor
ref="blockEditor"
v-model="form.content"
v-model:editor-mode="editorMode"
mode-toggle-teleport-to="#admin-post-form-mode-toggle-host"
@block-panel="onEditorBlockPanel"
/>
</div>
</section>
</main>
</div>
<aside
class="admin-post-form__settings flex h-screen shrink-0 flex-col overflow-hidden border-[#e3e6e8] bg-white transition-[width,border-color] duration-300 ease-out max-lg:fixed max-lg:inset-y-0 max-lg:right-0 max-lg:z-50 max-lg:max-w-[calc(100vw-40px)] max-lg:shadow-[-18px_0_60px_rgba(15,23,42,0.14)]"
:class="isSettingsOpen ? 'w-[420px] border-l' : 'w-0 border-l-0'"
:aria-hidden="!isSettingsOpen"
>
<div class="admin-post-form__settings-inner relative flex h-full w-[420px] max-lg:w-full flex-col">
<div class="admin-post-form__settings-header flex h-[56px] shrink-0 items-center justify-between px-6">
<h2 class="admin-post-form__settings-title text-xl font-bold text-black">
게시물 설정
</h2>
<button class="admin-post-form__settings-close grid size-8 place-items-center rounded text-neutral-900 transition-colors hover:bg-[#eff1f2] hover:text-neutral-500" type="button" aria-label="게시물 설정 닫기" @click="toggleSettingsPanel">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="16" width="16" class="h-[1.1rem] w-[1.1rem]" aria-hidden="true">
<path stroke-linecap="round" stroke-width="0.4" fill="currentColor" stroke="#000000" stroke-linejoin="round" d="M.44,21.44a1.49,1.49,0,0,0,0,2.12,1.5,1.5,0,0,0,2.12,0l9.26-9.26a.25.25,0,0,1,.36,0l9.26,9.26a1.5,1.5,0,0,0,2.12,0,1.49,1.49,0,0,0,0-2.12L14.3,12.18a.25.25,0,0,1,0-.36l9.26-9.26A1.5,1.5,0,0,0,21.44.44L12.18,9.7a.25.25,0,0,1-.36,0L2.56.44A1.5,1.5,0,0,0,.44,2.56L9.7,11.82a.25.25,0,0,1,0,.36Z" />
</svg>
</button>
</div>
<div class="admin-post-form__settings-body grid flex-1 content-start gap-4 overflow-y-auto px-6 pb-8 pt-8">
<div class="admin-post-form__field grid gap-1 text-sm">
<div class="admin-post-form__post-url-header flex h-[22px] items-center justify-between">
<span class="admin-post-form__label font-bold text-[#15171a]">Post URL</span>
<NuxtLink
v-if="canViewPost && viewPostUrl"
class="admin-post-form__view-post inline-flex items-center gap-1 rounded px-3 py-1.5 text-xs text-[#8e9cac] transition-colors hover:bg-[#eff1f2] hover:text-[#394047]"
:to="viewPostUrl"
target="_blank"
>
<span>View Post</span>
<span aria-hidden="true">↗</span>
</NuxtLink>
</div>
<label
class="admin-post-form__post-url-input flex h-[38px] items-center gap-2 rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-1 transition-colors hover:border-[#c8ced3] focus-within:border-[#8e9cac]"
:class="{ 'admin-post-form__post-url-input--auto': isSlugAutoFromTitle }"
>
<span class="admin-post-form__post-url-icon text-sm text-[#394047]" aria-hidden="true">⌘</span>
<input
v-model="slugInputValue"
class="admin-post-form__input min-w-0 flex-1 border-0 bg-transparent p-0 text-sm outline-none"
:class="isSlugAutoFromTitle ? 'text-[#8e9cac]' : 'text-black'"
type="text"
pattern="[a-z0-9가-힣]+(-[a-z0-9가-힣]+)*"
:required="!isSlugAutoFromTitle"
:aria-describedby="slugFieldError ? 'admin-post-form-slug-error' : (isSlugAutoFromTitle ? 'admin-post-form-slug-auto-hint' : undefined)"
@blur="commitSlugInput"
>
</label>
<p
v-if="isSlugAutoFromTitle"
id="admin-post-form-slug-auto-hint"
class="admin-post-form__post-url-auto-hint text-xs text-[#8e9cac]"
>
제목에서 자동 생성됩니다. 직접 수정하면 고정됩니다.
</p>
<p class="admin-post-form__post-url-hint text-xs text-[#7c8b9a]">
{{ postUrlHint }}
<span v-if="hasUnsavedSlugChange" class="admin-post-form__post-url-hint-unsaved text-[#8e9cac]"> (저장 후 반영)</span>
</p>
<p
v-if="slugFieldError"
id="admin-post-form-slug-error"
class="admin-post-form__post-url-error text-xs font-semibold text-red-600"
>
{{ slugFieldError }}
</p>
</div>
<label class="admin-post-form__field grid gap-2 text-sm">
<span class="admin-post-form__label font-medium">상태</span>
<span class="admin-post-form__select-wrap relative block">
<select v-model="form.status" class="admin-post-form__select h-[38px] w-full appearance-none rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-2 pr-10 transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac] focus:outline-none">
<option value="draft">초안</option>
<option value="published">발행</option>
<option value="members">멤버십</option>
<option value="private">비공개</option>
</select>
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-chevron-down pointer-events-none absolute right-3 top-1/2 size-5 -translate-y-1/2 text-[#15171a]" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<polyline points="6 9 12 15 18 9" />
</svg>
</span>
<span v-if="form.status === 'members'" class="admin-post-form__hint text-xs text-muted">
VIP 이상 등급 회원에게만 공개됩니다.
</span>
<span v-else-if="form.status === 'private'" class="admin-post-form__hint text-xs text-muted">
공개 사용자 화면에서는 보이지 않습니다.
</span>
</label>
<div v-if="form.status === 'published'" class="admin-post-form__field grid gap-2 text-sm">
<span class="admin-post-form__label font-medium">발행 시각</span>
<div class="admin-post-form__publish-datetime-row grid grid-cols-2 gap-2">
<label class="admin-post-form__publish-date-field relative block">
<span class="sr-only">발행 날짜</span>
<input
v-model="settingsPublishDate"
class="admin-post-form__publish-date-input h-[38px] w-full rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-2 pr-9 text-sm text-[#15171a] transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac] focus:outline-none"
type="date"
>
<button
class="admin-post-form__publish-date-icon absolute right-1 top-1/2 grid size-8 -translate-y-1/2 place-items-center rounded text-[#8e9cac] transition-colors hover:bg-[#e3e6e8] hover:text-[#394047]"
type="button"
tabindex="-1"
aria-label="발행 날짜 선택"
@click="openDateTimePicker"
>
<svg class="size-4" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M8 2v3M16 2v3M3 9h18M5 5h14a2 2 0 012 2v13a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2z" stroke="currentColor" stroke-linecap="round" stroke-width="1.5" />
</svg>
</button>
</label>
<label class="admin-post-form__publish-time-field relative block">
<span class="sr-only">발행 시각</span>
<input
v-model="settingsPublishTime"
class="admin-post-form__publish-time-input h-[38px] w-full rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-2 pr-12 text-sm text-[#15171a] transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac] focus:outline-none"
type="time"
>
<button
class="admin-post-form__publish-time-zone absolute right-1 top-1/2 grid h-8 min-w-[2.5rem] -translate-y-1/2 place-items-center rounded px-2 text-xs font-medium text-[#8e9cac] transition-colors hover:bg-[#e3e6e8] hover:text-[#394047]"
type="button"
tabindex="-1"
aria-label="발행 시각 선택"
@click="openDateTimePicker"
>
KST
</button>
</label>
</div>
<span class="admin-post-form__hint text-xs text-muted">
{{ isScheduledPost() ? '미래 시각이면 예약 발행으로 저장됩니다.' : '비워두면 저장 시점으로 발행됩니다.' }}
</span>
</div>
<label class="admin-post-form__field grid gap-2 text-sm">
<span class="admin-post-form__label font-medium">요약</span>
<textarea
v-model="form.excerpt"
class="admin-post-form__textarea min-h-[108px] rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-2 transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac] focus:outline-none"
/>
</label>
<div class="admin-post-form__field grid gap-1 text-sm">
<span class="admin-post-form__label h-[22px] font-bold text-[#15171a]">Tags</span>
<div class="admin-post-form__tag-combobox relative">
<div
class="admin-post-form__tag-editor flex min-h-[38px] w-full flex-wrap items-center gap-2 rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-1 transition-colors hover:border-[#c8ced3] focus-within:border-[#8e9cac]"
:class="!canAddMoreTags ? 'opacity-85' : ''"
>
<span
v-for="tag in selectedTags"
:key="tag"
class="admin-post-form__tag-badge inline-flex h-6 shrink-0 items-center gap-1.5 rounded-[3px] border px-2 text-sm font-semibold"
:style="createTagBadgeStyle(getTagColor(tag))"
>
<span>{{ getTagDisplayName(tag) }}</span>
<button
class="admin-post-form__tag-remove inline-flex size-4 shrink-0 items-center justify-center rounded text-current transition-colors hover:bg-white/45"
type="button"
:aria-label="`${getTagDisplayName(tag)} 태그 삭제`"
@click="removeTag(tag)"
>
<svg class="size-2.5" version="1" viewBox="0 0 24 24" aria-hidden="true">
<path fill="currentColor" d="M12.707 12L23.854.854a.5.5 0 00-.707-.707L12 11.293.854.146a.5.5 0 00-.707.707L11.293 12 .146 23.146a.5.5 0 00.708.708L12 12.707l11.146 11.146a.5.5 0 10.708-.706L12.707 12z" />
</svg>
</button>
</span>
<input
ref="tagInputRef"
v-model="tagInput"
class="admin-post-form__tag-input min-w-0 flex-1 border-0 bg-transparent p-0 text-sm text-black outline-none placeholder:text-[#8e9cac]"
type="text"
:placeholder="canAddMoreTags ? '태그 입력' : `최대 ${postTagLimit}개까지 선택됨`"
:disabled="!canAddMoreTags"
role="combobox"
:aria-expanded="hasTagSuggestions"
aria-autocomplete="list"
@focus="openTagSuggestions"
@input="openTagSuggestions"
@compositionupdate="tagInput = $event.target.value; openTagSuggestions()"
@blur="handleTagBlur"
@keydown="handleTagKeydown"
@compositionstart="isTagInputComposing = true"
@compositionend="isTagInputComposing = false"
>
<button
class="admin-post-form__tag-dropdown-trigger ml-auto inline-flex size-8 shrink-0 items-center justify-center rounded text-[#15171a] transition-colors hover:bg-[#e3e6e8] focus-visible:outline focus-visible:ring-2 focus-visible:ring-[#8e9cac]"
type="button"
:aria-expanded="hasTagSuggestions"
:disabled="!canAddMoreTags"
aria-label="태그 추천 목록 열기"
@mousedown.prevent="toggleTagSuggestions"
>
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-chevron-down size-5" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
</div>
<p class="mt-1 text-xs text-[#8e9cac]">
{{ selectedTags.length }} / {{ postTagLimit }}개 선택됨
</p>
<div
v-if="hasTagSuggestions"
class="admin-post-form__tag-suggestions absolute left-0 right-0 top-full z-40 mt-1 max-h-64 overflow-y-auto rounded-lg border border-[#d7dce0] bg-white py-1 text-sm shadow-[0_18px_50px_rgba(15,23,42,0.14)]"
role="listbox"
>
<button
v-for="(tag, index) in tagSuggestionOptions"
:key="tag.id || tag.slug"
class="admin-post-form__tag-suggestion flex w-full items-center justify-between gap-3 px-3 py-2 text-left transition-colors"
:class="index === activeTagSuggestionIndex ? 'bg-[#f3f5f7]' : 'hover:bg-[#f7f8f9]'"
type="button"
role="option"
:aria-selected="index === activeTagSuggestionIndex"
@mouseenter="activeTagSuggestionIndex = index"
@mousedown.prevent="selectTagSuggestion(tag)"
>
<span class="admin-post-form__tag-suggestion-name min-w-0 truncate font-semibold text-[#15171a]">{{ tag.name }}</span>
<span class="admin-post-form__tag-suggestion-meta shrink-0 text-xs text-muted">{{ getTagSuggestionMeta(tag) }}</span>
</button>
</div>
</div>
</div>
<label
class="admin-post-form__featured-image-display-toggle flex items-center justify-between gap-4 border-t border-[#e3e6e8] pt-5 text-sm"
>
<span class="admin-post-form__featured-image-display-copy flex min-w-0 items-center gap-3">
<span class="admin-post-form__featured-image-display-icon flex size-7 shrink-0 items-center justify-center text-[#15171a]" aria-hidden="true">
<svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="5" width="18" height="14" rx="2" />
<path d="m7 15 3-3 2 2 3-4 2 5" />
<circle cx="8" cy="9" r="1" />
</svg>
</span>
<span class="admin-post-form__featured-image-display-label grid min-w-0 gap-0.5">
<span class="font-bold text-[#15171a]">대표 이미지 표시</span>
</span>
</span>
<span class="admin-post-form__featured-image-display-control relative inline-flex h-7 w-12 shrink-0 items-center">
<input
v-model="form.showFeaturedImage"
class="peer sr-only"
type="checkbox"
aria-label="대표 이미지 표시"
>
<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>
<label class="admin-post-form__featured-toggle flex items-center justify-between gap-4 border-t border-[#e3e6e8] pt-5 text-sm">
<span class="admin-post-form__featured-toggle-copy flex min-w-0 items-center gap-3">
<span class="admin-post-form__featured-toggle-icon flex size-7 shrink-0 items-center justify-center text-[#15171a]" aria-hidden="true">
<svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3.5l2.7 5.47 6.04.88-4.37 4.26 1.03 6.01L12 17.28l-5.4 2.84 1.03-6.01-4.37-4.26 6.04-.88L12 3.5z" />
</svg>
</span>
<span class="admin-post-form__featured-toggle-label font-bold text-[#15171a]">
추천 글
</span>
</span>
<span class="admin-post-form__featured-toggle-control relative inline-flex h-7 w-12 shrink-0 items-center">
<input
v-model="form.isFeatured"
class="peer sr-only"
type="checkbox"
aria-label="추천 글로 표시"
>
<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>
<label class="admin-post-form__search-toggle flex items-center justify-between gap-4 border-t border-[#e3e6e8] pt-5 text-sm">
<span class="admin-post-form__search-toggle-copy flex min-w-0 items-center gap-3">
<span class="admin-post-form__search-toggle-icon flex size-7 shrink-0 items-center justify-center text-[#15171a]" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M480-100q-78.77 0-148.11-29.96-69.35-29.96-120.66-81.27-51.31-51.31-81.27-120.66Q100-401.23 100-480q0-78.77 29.96-148.11 29.96-69.35 81.27-120.66 51.31-51.31 120.66-81.27Q401.23-860 480-860q142.92 0 248.38 91.89 105.47 91.88 126.31 228.73h-61.23q-14.77-79.93-65.61-143.2Q677-745.85 600-776.77V-760q0 33-23.5 56.5T520-680h-80v80q0 17-11.5 28.5T400-560h-80v80h76.92v120H360L168-552q-3 18-5.5 36t-2.5 36q0 131 92 225t228 95v60Zm362.46-15.39-133-132.23q-19.46 12.39-41.92 20Q645.08-220 620-220q-66.92 0-113.46-46.54Q460-313.08 460-380q0-66.92 46.54-113.46Q553.08-540 620-540q66.92 0 113.46 46.54Q780-446.92 780-380q0 25.46-7.81 48.12-7.81 22.65-20.58 42.11l133 132.23-42.15 42.15ZM691-309q29-29 29-71t-29-71q-29-29-71-29t-71 29q-29 29-29 71t29 71q29 29 71 29t71-29Z"/></svg>
</span>
<span class="admin-post-form__search-toggle-label font-bold text-[#15171a]">
검색엔진 노출
</span>
</span>
<span class="admin-post-form__search-toggle-control relative inline-flex h-7 w-12 shrink-0 items-center">
<input
v-model="searchEngineVisible"
class="peer sr-only"
type="checkbox"
aria-label="검색엔진에 노출"
>
<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>
<div class="admin-post-form__settings-bottom grid shrink-0 gap-4 border-t border-[#e3e6e8] px-8 py-6">
<section class="admin-post-form__writing-stats grid gap-2 text-xs text-[#657080]" aria-label="본문 통계">
<div class="admin-post-form__writing-stats-row flex items-center justify-between gap-3">
<span class="admin-post-form__writing-stats-label">단어</span>
<span class="admin-post-form__writing-stats-value font-medium text-[#394047]">{{ formatStatNumber(postWritingStats.words) }}개</span>
</div>
<div class="admin-post-form__writing-stats-row flex items-center justify-between gap-3">
<span class="admin-post-form__writing-stats-label">문자</span>
<span class="admin-post-form__writing-stats-value font-medium text-[#394047]">{{ formatStatNumber(postWritingStats.characters) }}개 <span class="text-[#8e9cac]">({{ formatStatNumber(postWritingStats.spaces) }} 공백)</span></span>
</div>
<div class="admin-post-form__writing-stats-row flex items-center justify-between gap-3">
<span class="admin-post-form__writing-stats-label">읽기 시간</span>
<span class="admin-post-form__writing-stats-value font-medium text-[#394047]">{{ postWritingStats.readingMinutes ? `약 ${formatStatNumber(postWritingStats.readingMinutes)}분` : '0분' }}</span>
</div>
<div class="admin-post-form__writing-stats-row flex items-center justify-between gap-3">
<span class="admin-post-form__writing-stats-label">구성</span>
<span class="admin-post-form__writing-stats-value font-medium text-[#394047]">블록 {{ formatStatNumber(postWritingStats.blocks) }}개 · 이미지 {{ formatStatNumber(postWritingStats.images) }}개</span>
</div>
</section>
<button
v-if="showDelete"
class="admin-post-form__delete-post flex h-10 w-full items-center justify-center gap-2 rounded border border-[#d7dde2] bg-white text-sm font-bold text-[#394047] transition-colors hover:border-[#d21a26] hover:bg-red-50 hover:text-[#d21a26] disabled:opacity-50"
type="button"
:disabled="deleting"
@click="deletePost"
>
<svg
class="admin-post-form__delete-post-icon size-5 shrink-0"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M17.923 22.308H6.077a1.692 1.692 0 01-1.692-1.693V5.385h15.23v15.23a1.692 1.692 0 01-1.692 1.693zM9.462 17.23v-6.769M14.539 17.23v-6.769M1 5.385h22M14.539 2H9.462a1.692 1.692 0 00-1.693 1.692v1.693h8.462V3.692A1.692 1.692 0 0014.538 2z"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
/>
</svg>
<span>{{ deleting ? '삭제 중' : 'Delete post' }}</span>
</button>
</div>
<AdminEditorBlockPanel
:open="editorBlockPanel.open"
:panel="editorBlockPanel.panel"
@panel-focus-in="onBlockPanelFocusIn"
@panel-focus-out="onBlockPanelFocusOut"
@update-media-image="onBlockPanelUpdateMediaImage"
@set-media-use-alt="onBlockPanelSetMediaUseAlt"
@move-gallery-image="onBlockPanelMoveGalleryImage"
@remove-media-image="onBlockPanelRemoveMediaImage"
@add-gallery-images="onBlockPanelAddGalleryImages"
@update-embed-url="onBlockPanelUpdateEmbedUrl"
@update-quote-background="onBlockPanelUpdateQuoteBackground"
@update-callout-options="onBlockPanelUpdateCalloutOptions"
@update-code-options="onBlockPanelUpdateCodeOptions"
@update-toggle-options="onBlockPanelUpdateToggleOptions"
@update-inline-color="onBlockPanelUpdateInlineColor"
/>
</div>
</aside>
<div
v-if="isMediaPickerOpen"
class="admin-post-form__media-picker fixed inset-0 z-50 grid place-items-center bg-black/40 px-5 py-8"
role="dialog"
aria-modal="true"
@click.self="closeMediaPicker"
>
<section class="admin-post-form__media-picker-panel flex h-[min(86vh,760px)] min-h-[620px] w-full max-w-5xl flex-col overflow-hidden bg-white text-ink shadow-xl">
<div class="admin-post-form__media-picker-header flex items-center justify-between border-b border-line px-5 py-4">
<h2 class="admin-post-form__media-picker-title text-lg font-semibold">
대표 이미지
</h2>
<button
class="admin-post-form__media-picker-close rounded border border-line px-3 py-1.5 text-sm font-semibold transition-colors hover:bg-[#eff1f2] disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="isUploadingFeaturedImage"
@click="closeMediaPicker"
>
닫기
</button>
</div>
<div class="admin-post-form__media-picker-tabs flex items-center gap-3 border-b border-line px-5">
<div class="flex min-w-0 flex-1 items-center">
<button
class="admin-post-form__media-picker-tab border-b-2 px-3 py-3 text-sm font-semibold transition-colors"
:class="activeMediaPickerTab === 'upload' ? 'border-[#15171a] text-[#15171a]' : 'border-transparent text-muted hover:text-[#15171a]'"
type="button"
@click="activeMediaPickerTab = 'upload'"
>
이미지 업로드
</button>
<button
class="admin-post-form__media-picker-tab border-b-2 px-3 py-3 text-sm font-semibold transition-colors"
:class="activeMediaPickerTab === 'library' ? 'border-[#15171a] text-[#15171a]' : 'border-transparent text-muted hover:text-[#15171a]'"
type="button"
@click="activeMediaPickerTab = 'library'"
>
미디어 라이브러리
</button>
</div>
<label v-if="activeMediaPickerTab === 'library'" class="admin-post-form__media-picker-search mb-px flex w-full max-w-xs shrink-0 items-center py-2">
<span class="sr-only">파일명 검색</span>
<input
:value="mediaSearchInput"
class="admin-post-form__media-picker-search-input w-full rounded border border-[#d7dde2] bg-white px-3 py-1.5 text-sm text-[#394047] outline-none transition-colors placeholder:text-[#8e9cac] focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]/20"
type="search"
placeholder="파일명 검색"
autocomplete="off"
@input="onMediaSearchInput"
@compositionupdate="onMediaSearchInput"
@compositionend="onMediaSearchCompositionEnd"
>
</label>
</div>
<div class="admin-post-form__media-picker-body min-h-0 flex-1 overflow-y-auto p-5">
<div
v-if="activeMediaPickerTab === 'upload'"
class="admin-post-form__media-upload-zone grid h-full min-h-[420px] place-items-center border border-dashed border-[#cfd5da] bg-[#fafafa] text-center"
:class="isUploadingFeaturedImage ? 'select-none border-[#8e9cac] bg-[#f3f5f7]' : ''"
@dragover.prevent
@drop.prevent="dropFeaturedImage"
>
<div class="admin-post-form__media-upload-inner grid gap-3">
<p class="admin-post-form__media-upload-title text-lg font-semibold text-[#15171a]">
{{ isUploadingFeaturedImage ? '업로드 중입니다' : '파일을 끌어 업로드' }}
</p>
<p class="admin-post-form__media-upload-or text-sm text-muted">
{{ isUploadingFeaturedImage ? '잠시만 기다려 주세요.' : '또는' }}
</p>
<label
class="admin-post-form__media-upload-button mx-auto inline-flex h-10 items-center justify-center rounded border border-[#2b78d0] px-8 text-sm font-semibold text-[#1f6fbf] transition-colors hover:bg-blue-50"
:class="isUploadingFeaturedImage ? 'cursor-not-allowed border-[#c8ced3] text-[#8e9cac] hover:bg-transparent' : 'cursor-pointer'"
>
{{ isUploadingFeaturedImage ? '업로드 중' : '파일 선택' }}
<input class="sr-only" type="file" accept="image/*" :disabled="isUploadingFeaturedImage" @change="uploadFeaturedImage">
</label>
</div>
</div>
<template v-else>
<p v-if="isLoadingMedia" class="admin-post-form__media-picker-loading py-12 text-center text-sm text-muted">
미디어를 불러오는 중입니다.
</p>
<div v-else-if="featuredImageMediaItems.length" class="admin-post-form__media-picker-grid grid grid-cols-2 gap-3 md:grid-cols-4">
<button
v-for="item in featuredImageMediaItems"
:key="item.url"
class="admin-post-form__media-picker-item relative overflow-hidden border bg-white text-left transition hover:border-[#8e9cac] hover:shadow-sm"
:class="selectedMediaPickerUrl === item.url ? 'border-[#15171a] ring-2 ring-[#15171a]' : 'border-line'"
type="button"
:aria-pressed="selectedMediaPickerUrl === item.url"
@click="selectPickedImage(item)"
>
<img
class="admin-post-form__media-picker-image aspect-[4/3] w-full bg-surface object-cover"
:src="getFeaturedMediaPreviewUrl(item)"
:alt="item.name || ''"
loading="lazy"
decoding="async"
>
<span class="admin-post-form__media-picker-name block truncate px-3 py-2 text-xs font-semibold text-ink">{{ item.name || item.url }}</span>
<span v-if="selectedMediaPickerUrl === item.url" class="admin-post-form__media-picker-selected absolute right-2 top-2 grid size-6 place-items-center rounded-full bg-[#15171a] text-white" aria-hidden="true">
<svg width="13" height="10" viewBox="0 0 13 10" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 5L4.5 8.5L12 1" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</span>
</button>
</div>
<p v-else class="admin-post-form__media-picker-empty border border-dashed border-line p-8 text-center text-sm text-muted">
<template v-if="mediaSearchQuery.trim()">
「{{ mediaSearchQuery.trim() }}」에 맞는 미디어가 없습니다.
</template>
<template v-else>
선택할 미디어가 없습니다.
</template>
</p>
</template>
</div>
<div class="admin-post-form__media-picker-footer flex min-h-14 shrink-0 flex-wrap items-center justify-between gap-3 border-t border-line px-5 py-3">
<div class="admin-post-form__media-picker-pagination flex flex-wrap items-center gap-2 text-xs font-semibold text-muted">
<span v-if="activeMediaPickerTab === 'library'">{{ mediaPickerRangeLabel }}</span>
<nav v-if="activeMediaPickerTab === 'library' && mediaPickerPageCount > 1" class="flex flex-wrap items-center gap-1" aria-label="대표 이미지 선택 페이지">
<button
class="admin-post-form__media-picker-page-button grid size-8 place-items-center rounded-full border border-[#d7dde2] bg-white text-[#394047] transition hover:border-[#8e9cac] disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="isLoadingMedia || mediaPickerPage <= 1"
aria-label="이전 페이지"
@click="setMediaPickerPage(mediaPickerPage - 1)"
>
‹
</button>
<template v-for="item in mediaPickerPageItems" :key="item">
<span v-if="typeof item === 'string'" class="px-1 text-[#8e9cac]">...</span>
<button
v-else
class="admin-post-form__media-picker-page-button grid size-8 place-items-center rounded-full text-xs font-semibold transition"
:class="item === mediaPickerPage ? 'bg-[#15171a] text-white' : 'text-[#8e9cac] hover:bg-[#f1f3f5] hover:text-[#394047]'"
type="button"
:disabled="isLoadingMedia"
@click="setMediaPickerPage(item)"
>
{{ item }}
</button>
</template>
<button
class="admin-post-form__media-picker-page-button grid size-8 place-items-center rounded-full border border-[#d7dde2] bg-white text-[#394047] transition hover:border-[#8e9cac] disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="isLoadingMedia || mediaPickerPage >= mediaPickerPageCount"
aria-label="다음 페이지"
@click="setMediaPickerPage(mediaPickerPage + 1)"
>
›
</button>
</nav>
</div>
<button
class="admin-post-form__media-picker-confirm h-9 rounded bg-[#15171a] px-4 text-sm font-semibold text-white transition-colors hover:bg-black disabled:cursor-not-allowed disabled:bg-[#d7dce0] disabled:text-[#8e9cac]"
type="button"
:disabled="isUploadingFeaturedImage || !selectedMediaPickerUrl"
@click="applyPickedImage"
>
대표 이미지로 적용
</button>
</div>
</section>
</div>
<AdminUnsavedChangesModal
:open="isUnsavedModalOpen"
@stay="stayOnUnsavedPage"
@leave="leaveUnsavedPage"
/>
<div
v-if="isPublishModalOpen"
class="admin-post-form__publish-modal fixed inset-0 z-[70] flex flex-col bg-white"
role="dialog"
aria-modal="true"
aria-labelledby="admin-post-form-publish-modal-title"
>
<header class="admin-post-form__publish-modal-header absolute inset-x-0 top-0 z-10 flex h-14 shrink-0 items-center justify-between bg-white/95 px-6 backdrop-blur-sm supports-[backdrop-filter]:bg-white/80">
<h2 id="admin-post-form-publish-modal-title" class="text-[15px] font-semibold text-[#15171a]">
발행
</h2>
<button class="rounded px-3 py-1.5 text-[13px] font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="closePublishModal">
닫기
</button>
</header>
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto pt-14">
<div class="admin-post-form__publish-modal-body flex min-h-[calc(100dvh-3.5rem)] flex-1 flex-col items-center justify-center px-6 pb-16">
<div class="w-full max-w-[640px]">
<div class="admin-post-form__publish-modal-hero mb-10 sm:mb-12">
<p class="text-[clamp(28px,7vw,46px)] font-black text-[#2bba3c]">
준비됐어요, 발행하세요.
</p>
<p class="text-[clamp(28px,7vw,46px)] font-black leading-[0.95] text-[#15171a]">
세상과 공유해 보세요.
</p>
</div>
<div class="admin-post-form__publish-settings w-full">
<div class="admin-post-form__publish-setting">
<button
class="admin-post-form__publish-setting-title flex w-full items-center gap-3 py-4 text-left text-[15px] font-semibold text-[#15171a] transition-colors"
type="button"
:aria-expanded="publishModalExpandedSection === 'status'"
aria-controls="admin-post-form-publish-status-panel"
data-test-setting="publish-type"
@click="togglePublishModalSection('status')"
>
<span class="flex size-6 shrink-0 items-center justify-center text-[#959eab]" aria-hidden="true">
<svg class="size-4" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M23 1L6.21 13.013v9.408L12 17.355" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
<path d="M1 9.105L23 1l-3.474 22L1 9.105z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
</svg>
</span>
<span class="min-w-0 flex-1">{{ publishStatusSummaryLabel }}</span>
<span class="flex size-2.5 shrink-0 items-center justify-center text-[#959eab]" aria-hidden="true">
<svg
class="size-[22px] transition-transform duration-200 ease-out"
:class="{ 'rotate-180': publishModalExpandedSection === 'status' }"
fill="currentColor"
viewBox="0 0 26 24"
xmlns="http://www.w3.org/2000/svg"
>
<path clip-rule="evenodd" d="M1.043 6.604a1 1 0 011.414 0L13 17.146 23.543 6.604a1 1 0 011.414 1.414l-10.72 10.719a1.75 1.75 0 01-2.474 0L1.042 8.018a1 1 0 010-1.414zm11.78 10.72v-.001zm.355 0v-.001z" fill-rule="evenodd" />
</svg>
</span>
</button>
<div
v-show="publishModalExpandedSection === 'status'"
id="admin-post-form-publish-status-panel"
class="admin-post-form__publish-setting-panel px-4 pb-4"
>
<div class="flex flex-wrap gap-2">
<button
class="rounded-full border px-3 py-1.5 text-[13px] font-semibold"
:class="publishStatus === 'published' ? 'border-[#15171a] bg-[#15171a] text-white' : 'border-[#d7dde2] bg-white text-[#394047]'"
type="button"
@click="selectPublishStatus('published')"
>
발행
</button>
<button
class="rounded-full border px-3 py-1.5 text-[13px] font-semibold"
:class="publishStatus === 'draft' ? 'border-[#15171a] bg-[#15171a] text-white' : 'border-[#d7dde2] bg-white text-[#394047]'"
type="button"
@click="selectPublishStatus('draft')"
>
초안
</button>
</div>
</div>
</div>
<div v-if="publishStatus === 'published'" class="admin-post-form__publish-setting admin-post-form__publish-setting--timing border-t border-[#e3e6e8]">
<button
class="admin-post-form__publish-setting-title flex w-full items-center gap-3 py-4 text-left text-[15px] font-semibold text-[#15171a] transition-colors"
type="button"
:aria-expanded="publishModalExpandedSection === 'timing'"
aria-controls="admin-post-form-publish-timing-panel"
data-test-setting="publish-at"
@click="togglePublishModalSection('timing')"
>
<span class="flex size-6 shrink-0 items-center justify-center text-[#959eab]" aria-hidden="true">
<svg class="size-4" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M12 23c6.075 0 11-4.925 11-11S18.075 1 12 1 1 5.925 1 12s4.925 11 11 11z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
<path d="M12 6v6h6" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
</svg>
</span>
<span class="min-w-0 flex-1">{{ publishTimingSummaryLabel }}</span>
<span class="flex size-2.5 shrink-0 items-center justify-center text-[#959eab]" aria-hidden="true">
<svg
class="size-[22px] transition-transform duration-200 ease-out"
:class="{ 'rotate-180': publishModalExpandedSection === 'timing' }"
fill="currentColor"
viewBox="0 0 26 24"
xmlns="http://www.w3.org/2000/svg"
>
<path clip-rule="evenodd" d="M1.043 6.604a1 1 0 011.414 0L13 17.146 23.543 6.604a1 1 0 011.414 1.414l-10.72 10.719a1.75 1.75 0 01-2.474 0L1.042 8.018a1 1 0 010-1.414zm11.78 10.72v-.001zm.355 0v-.001z" fill-rule="evenodd" />
</svg>
</span>
</button>
<div
v-show="publishModalExpandedSection === 'timing'"
id="admin-post-form-publish-timing-panel"
class="admin-post-form__publish-setting-panel px-4 pb-4"
>
<div class="flex flex-wrap gap-2">
<button
class="rounded-full border px-3 py-1.5 text-[13px] font-semibold"
:class="publishTiming === 'now' ? 'border-[#15171a] bg-[#15171a] text-white' : 'border-[#d7dde2] bg-white text-[#394047]'"
type="button"
@click="selectPublishTiming('now')"
>
지금 바로
</button>
<button
class="rounded-full border px-3 py-1.5 text-[13px] font-semibold"
:class="publishTiming === 'schedule' ? 'border-[#15171a] bg-[#15171a] text-white' : 'border-[#d7dde2] bg-white text-[#394047]'"
type="button"
@click="selectPublishTiming('schedule')"
>
예약
</button>
</div>
<div v-if="publishTiming === 'schedule'" class="admin-post-form__publish-schedule-datetime mt-3 grid max-w-[360px] grid-cols-2 gap-2">
<label class="admin-post-form__publish-date-field relative block">
<span class="sr-only">예약 발행 날짜</span>
<input
v-model="modalScheduleDate"
class="admin-post-form__publish-date-input h-[38px] w-full rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-2 pr-9 text-sm text-[#15171a] transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac] focus:outline-none"
type="date"
>
<button
class="admin-post-form__publish-date-icon absolute right-1 top-1/2 grid size-8 -translate-y-1/2 place-items-center rounded text-[#8e9cac] transition-colors hover:bg-[#e3e6e8] hover:text-[#394047]"
type="button"
tabindex="-1"
aria-label="예약 발행 날짜 선택"
@click="openDateTimePicker"
>
<svg class="size-4" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M8 2v3M16 2v3M3 9h18M5 5h14a2 2 0 012 2v13a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2z" stroke="currentColor" stroke-linecap="round" stroke-width="1.5" />
</svg>
</button>
</label>
<label class="admin-post-form__publish-time-field relative block">
<span class="sr-only">예약 발행 시각</span>
<input
v-model="modalScheduleTime"
class="admin-post-form__publish-time-input h-[38px] w-full rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-2 pr-12 text-sm text-[#15171a] transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac] focus:outline-none"
type="time"
>
<button
class="admin-post-form__publish-time-zone absolute right-1 top-1/2 grid h-8 min-w-[2.5rem] -translate-y-1/2 place-items-center rounded px-2 text-xs font-medium text-[#8e9cac] transition-colors hover:bg-[#e3e6e8] hover:text-[#394047]"
type="button"
tabindex="-1"
aria-label="예약 발행 시각 선택"
@click="openDateTimePicker"
>
KST
</button>
</label>
</div>
</div>
</div>
</div>
<div class="admin-post-form__publish-modal-actions mt-10">
<button
class="rounded bg-[#15171a] px-5 py-2.5 text-[14px] font-semibold text-white transition-colors hover:bg-black disabled:cursor-not-allowed disabled:bg-[#d7dce0] disabled:text-[#8e9cac]"
type="button"
:disabled="hasBlockingSlugError || saving || autoSaving"
@click="submitFromPublishModal"
>
{{ saving || autoSaving ? '저장 중…' : '최종 확인하고 저장 →' }}
</button>
</div>
</div>
</div>
</div>
</div>
<div
v-if="isUnpublishModalOpen"
class="admin-post-form__unpublish-modal fixed inset-0 z-[70] flex flex-col bg-white"
role="dialog"
aria-modal="true"
aria-labelledby="admin-post-form-unpublish-modal-title"
>
<header class="admin-post-form__unpublish-modal-header absolute inset-x-0 top-0 z-10 flex h-14 shrink-0 items-center justify-between bg-white/95 px-6 backdrop-blur-sm supports-[backdrop-filter]:bg-white/80">
<h2 id="admin-post-form-unpublish-modal-title" class="text-[15px] font-semibold text-[#15171a]">
{{ unpublishModalKind === 'scheduled' ? '예약 취소' : '발행 취소' }}
</h2>
<button class="rounded border border-[#d7dde2] px-3 py-1.5 text-[13px] font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="closeUnpublishModal">
닫기
</button>
</header>
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto pt-14">
<div class="admin-post-form__unpublish-modal-body flex min-h-[calc(100dvh-3.5rem)] flex-1 flex-col items-center justify-center px-6 pb-16">
<div class="w-full max-w-[640px]">
<div class="admin-post-form__unpublish-modal-hero mb-6">
<p class="text-[clamp(28px,7vw,46px)] font-black leading-[1.05] text-[#15171a]">
<template v-if="unpublishModalKind === 'scheduled'">
이 글은 <span class="text-[#2bba3c]">예약</span>되어 있습니다
</template>
<template v-else>
이 글은 <span class="text-[#2bba3c]">발행</span>되었습니다
</template>
</p>
</div>
<p v-if="unpublishPersistedPublishedLabel" class="admin-post-form__unpublish-modal-summary mb-8 text-base text-[#394047]">
<template v-if="unpublishModalKind === 'scheduled'">
{{ unpublishPersistedPublishedLabel }}에 발행되도록 예약되어 있습니다.
</template>
<template v-else>
사이트에 {{ unpublishPersistedPublishedLabel }}에 발행되었습니다.
</template>
</p>
<button
class="admin-post-form__unpublish-confirm inline-flex items-center gap-1 text-base font-semibold text-[#2bba3c] transition-colors hover:text-[#229a32] disabled:opacity-50"
type="button"
:disabled="saving"
@click="confirmUnpublishOrUnschedule"
>
<span>{{ unpublishModalKind === 'scheduled' ? '예약 취소하고 초안으로 되돌리기' : '발행 취소하고 초안으로 되돌리기' }}</span>
<span aria-hidden="true">→</span>
</button>
</div>
</div>
</div>
</div>
</form>
</template>
<style scoped>
.admin-post-form__publish-date-input,
.admin-post-form__publish-time-input {
position: relative;
-webkit-appearance: none;
appearance: none;
}
.admin-post-form__publish-date-input::-webkit-calendar-picker-indicator,
.admin-post-form__publish-time-input::-webkit-calendar-picker-indicator {
position: absolute;
top: 0;
right: 0;
width: 2.25rem;
height: 100%;
margin: 0;
padding: 0;
cursor: pointer;
opacity: 0;
}
</style>