게시물 설정 패널 렌더링 분리
This commit is contained in:
@@ -360,6 +360,15 @@ const {
|
||||
siteSettings
|
||||
})
|
||||
|
||||
/**
|
||||
* 분리된 설정 사이드바의 태그 입력 요소를 태그 composable에 연결한다.
|
||||
* @param {HTMLInputElement | null} element - 태그 입력 요소
|
||||
* @returns {void}
|
||||
*/
|
||||
const setTagInputElement = (element) => {
|
||||
tagInputRef.value = element
|
||||
}
|
||||
|
||||
const {
|
||||
activeMediaPickerTab,
|
||||
applyPickedImage,
|
||||
@@ -1088,366 +1097,63 @@ defineExpose({
|
||||
</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>
|
||||
<AdminPopoverSurface
|
||||
v-if="hasTagSuggestions"
|
||||
root-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-[#d7dce0] py-1 text-sm"
|
||||
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>
|
||||
</AdminPopoverSurface>
|
||||
</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>
|
||||
<AdminPostSettingsSidebar
|
||||
v-model:slug-input-value="slugInputValue"
|
||||
v-model:settings-publish-date="settingsPublishDate"
|
||||
v-model:settings-publish-time="settingsPublishTime"
|
||||
v-model:tag-input="tagInput"
|
||||
v-model:active-tag-suggestion-index="activeTagSuggestionIndex"
|
||||
v-model:search-engine-visible="searchEngineVisible"
|
||||
:active="isSettingsOpen"
|
||||
:can-add-more-tags="canAddMoreTags"
|
||||
:can-view-post="canViewPost"
|
||||
:create-tag-badge-style="createTagBadgeStyle"
|
||||
:deleting="deleting"
|
||||
:editor-block-panel="editorBlockPanel"
|
||||
:form="form"
|
||||
:format-stat-number="formatStatNumber"
|
||||
:get-tag-color="getTagColor"
|
||||
:get-tag-display-name="getTagDisplayName"
|
||||
:get-tag-suggestion-meta="getTagSuggestionMeta"
|
||||
:has-tag-suggestions="hasTagSuggestions"
|
||||
:has-unsaved-slug-change="hasUnsavedSlugChange"
|
||||
:is-scheduled-post="isScheduledPost"
|
||||
:is-slug-auto-from-title="isSlugAutoFromTitle"
|
||||
:post-tag-limit="postTagLimit"
|
||||
:post-url-hint="postUrlHint"
|
||||
:post-writing-stats="postWritingStats"
|
||||
:selected-tags="selectedTags"
|
||||
:show-delete="showDelete"
|
||||
:slug-field-error="slugFieldError"
|
||||
:tag-suggestion-options="tagSuggestionOptions"
|
||||
:view-post-url="viewPostUrl"
|
||||
@add-gallery-images="onBlockPanelAddGalleryImages"
|
||||
@close="toggleSettingsPanel"
|
||||
@commit-slug-input="commitSlugInput"
|
||||
@delete-post="deletePost"
|
||||
@move-gallery-image="onBlockPanelMoveGalleryImage"
|
||||
@open-date-time-picker="openDateTimePicker"
|
||||
@open-tag-suggestions="openTagSuggestions"
|
||||
@panel-focus-in="onBlockPanelFocusIn"
|
||||
@panel-focus-out="onBlockPanelFocusOut"
|
||||
@remove-media-image="onBlockPanelRemoveMediaImage"
|
||||
@remove-tag="removeTag"
|
||||
@select-tag-suggestion="selectTagSuggestion"
|
||||
@set-media-use-alt="onBlockPanelSetMediaUseAlt"
|
||||
@set-tag-input-ref="setTagInputElement"
|
||||
@tag-blur="handleTagBlur"
|
||||
@tag-composition-end="isTagInputComposing = false"
|
||||
@tag-composition-start="isTagInputComposing = true"
|
||||
@tag-keydown="handleTagKeydown"
|
||||
@toggle-tag-suggestions="toggleTagSuggestions"
|
||||
@update-callout-options="onBlockPanelUpdateCalloutOptions"
|
||||
@update-code-options="onBlockPanelUpdateCodeOptions"
|
||||
@update-embed-url="onBlockPanelUpdateEmbedUrl"
|
||||
@update-inline-color="onBlockPanelUpdateInlineColor"
|
||||
@update-media-image="onBlockPanelUpdateMediaImage"
|
||||
@update-quote-background="onBlockPanelUpdateQuoteBackground"
|
||||
@update-toggle-options="onBlockPanelUpdateToggleOptions"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="isMediaPickerOpen"
|
||||
|
||||
523
components/admin/AdminPostSettingsSidebar.vue
Normal file
523
components/admin/AdminPostSettingsSidebar.vue
Normal file
@@ -0,0 +1,523 @@
|
||||
<script setup>
|
||||
const slugInputValue = defineModel('slugInputValue', {
|
||||
type: String,
|
||||
default: ''
|
||||
})
|
||||
const settingsPublishDate = defineModel('settingsPublishDate', {
|
||||
type: String,
|
||||
default: ''
|
||||
})
|
||||
const settingsPublishTime = defineModel('settingsPublishTime', {
|
||||
type: String,
|
||||
default: ''
|
||||
})
|
||||
const tagInput = defineModel('tagInput', {
|
||||
type: String,
|
||||
default: ''
|
||||
})
|
||||
const activeTagSuggestionIndex = defineModel('activeTagSuggestionIndex', {
|
||||
type: Number,
|
||||
default: 0
|
||||
})
|
||||
const searchEngineVisible = defineModel('searchEngineVisible', {
|
||||
type: Boolean,
|
||||
default: true
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
canAddMoreTags: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
canViewPost: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
createTagBadgeStyle: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
deleting: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
editorBlockPanel: {
|
||||
type: Object,
|
||||
default: () => ({ open: false, panel: null })
|
||||
},
|
||||
form: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
formatStatNumber: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
getTagColor: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
getTagDisplayName: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
getTagSuggestionMeta: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
hasTagSuggestions: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
hasUnsavedSlugChange: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
isScheduledPost: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
isSlugAutoFromTitle: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
postTagLimit: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
postUrlHint: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
postWritingStats: {
|
||||
type: Object,
|
||||
default: () => ({ words: 0, characters: 0, spaces: 0, readingMinutes: 0, blocks: 0, images: 0 })
|
||||
},
|
||||
selectedTags: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
showDelete: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
slugFieldError: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
tagSuggestionOptions: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
viewPostUrl: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'add-gallery-images',
|
||||
'close',
|
||||
'commit-slug-input',
|
||||
'delete-post',
|
||||
'move-gallery-image',
|
||||
'open-date-time-picker',
|
||||
'panel-focus-in',
|
||||
'panel-focus-out',
|
||||
'remove-media-image',
|
||||
'remove-tag',
|
||||
'select-tag-suggestion',
|
||||
'set-media-use-alt',
|
||||
'set-tag-input-ref',
|
||||
'tag-composition-end',
|
||||
'tag-composition-start',
|
||||
'toggle-tag-suggestions',
|
||||
'open-tag-suggestions',
|
||||
'tag-blur',
|
||||
'tag-keydown',
|
||||
'update-callout-options',
|
||||
'update-code-options',
|
||||
'update-embed-url',
|
||||
'update-inline-color',
|
||||
'update-media-image',
|
||||
'update-quote-background',
|
||||
'update-toggle-options'
|
||||
])
|
||||
|
||||
/**
|
||||
* 태그 입력 요소 참조를 부모 태그 상태로 전달한다.
|
||||
* @param {HTMLInputElement | null} element - 태그 입력 요소
|
||||
* @returns {void}
|
||||
*/
|
||||
const setTagInputRef = (element) => {
|
||||
emit('set-tag-input-ref', element)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside
|
||||
class="admin-post-settings-sidebar 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="props.active ? 'w-[420px] border-l' : 'w-0 border-l-0'"
|
||||
:aria-hidden="!props.active"
|
||||
>
|
||||
<div class="admin-post-settings-sidebar__inner admin-post-form__settings-inner relative flex h-full w-[420px] max-lg:w-full flex-col">
|
||||
<div class="admin-post-settings-sidebar__header admin-post-form__settings-header flex h-[56px] shrink-0 items-center justify-between px-6">
|
||||
<h2 class="admin-post-settings-sidebar__title admin-post-form__settings-title text-xl font-bold text-black">
|
||||
게시물 설정
|
||||
</h2>
|
||||
<button class="admin-post-settings-sidebar__close 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="emit('close')">
|
||||
<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-settings-sidebar__body 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-settings-sidebar__field 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="props.canViewPost && props.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="props.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': props.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="props.isSlugAutoFromTitle ? 'text-[#8e9cac]' : 'text-black'"
|
||||
type="text"
|
||||
pattern="[a-z0-9가-힣]+(-[a-z0-9가-힣]+)*"
|
||||
:required="!props.isSlugAutoFromTitle"
|
||||
:aria-describedby="props.slugFieldError ? 'admin-post-form-slug-error' : (props.isSlugAutoFromTitle ? 'admin-post-form-slug-auto-hint' : undefined)"
|
||||
@blur="emit('commit-slug-input')"
|
||||
>
|
||||
</label>
|
||||
<p
|
||||
v-if="props.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]">
|
||||
{{ props.postUrlHint }}
|
||||
<span v-if="props.hasUnsavedSlugChange" class="admin-post-form__post-url-hint-unsaved text-[#8e9cac]"> (저장 후 반영)</span>
|
||||
</p>
|
||||
<p
|
||||
v-if="props.slugFieldError"
|
||||
id="admin-post-form-slug-error"
|
||||
class="admin-post-form__post-url-error text-xs font-semibold text-red-600"
|
||||
>
|
||||
{{ props.slugFieldError }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label class="admin-post-settings-sidebar__field 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="props.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="props.form.status === 'members'" class="admin-post-form__hint text-xs text-muted">
|
||||
VIP 이상 등급 회원에게만 공개됩니다.
|
||||
</span>
|
||||
<span v-else-if="props.form.status === 'private'" class="admin-post-form__hint text-xs text-muted">
|
||||
공개 사용자 화면에서는 보이지 않습니다.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div v-if="props.form.status === 'published'" class="admin-post-settings-sidebar__field 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="emit('open-date-time-picker', $event)"
|
||||
>
|
||||
<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="emit('open-date-time-picker', $event)"
|
||||
>
|
||||
KST
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
<span class="admin-post-form__hint text-xs text-muted">
|
||||
{{ props.isScheduledPost() ? '미래 시각이면 예약 발행으로 저장됩니다.' : '비워두면 저장 시점으로 발행됩니다.' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<label class="admin-post-settings-sidebar__field admin-post-form__field grid gap-2 text-sm">
|
||||
<span class="admin-post-form__label font-medium">요약</span>
|
||||
<textarea
|
||||
v-model="props.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-settings-sidebar__field 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="!props.canAddMoreTags ? 'opacity-85' : ''"
|
||||
>
|
||||
<span
|
||||
v-for="tag in props.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="props.createTagBadgeStyle(props.getTagColor(tag))"
|
||||
>
|
||||
<span>{{ props.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="`${props.getTagDisplayName(tag)} 태그 삭제`"
|
||||
@click="emit('remove-tag', 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="setTagInputRef"
|
||||
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="props.canAddMoreTags ? '태그 입력' : `최대 ${props.postTagLimit}개까지 선택됨`"
|
||||
:disabled="!props.canAddMoreTags"
|
||||
role="combobox"
|
||||
:aria-expanded="props.hasTagSuggestions"
|
||||
aria-autocomplete="list"
|
||||
@focus="emit('open-tag-suggestions')"
|
||||
@input="emit('open-tag-suggestions')"
|
||||
@compositionupdate="tagInput = $event.target.value; emit('open-tag-suggestions')"
|
||||
@blur="emit('tag-blur')"
|
||||
@keydown="emit('tag-keydown', $event)"
|
||||
@compositionstart="emit('tag-composition-start')"
|
||||
@compositionend="emit('tag-composition-end')"
|
||||
>
|
||||
<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="props.hasTagSuggestions"
|
||||
:disabled="!props.canAddMoreTags"
|
||||
aria-label="태그 추천 목록 열기"
|
||||
@mousedown.prevent="emit('toggle-tag-suggestions')"
|
||||
>
|
||||
<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]">
|
||||
{{ props.selectedTags.length }} / {{ props.postTagLimit }}개 선택됨
|
||||
</p>
|
||||
<AdminPopoverSurface
|
||||
v-if="props.hasTagSuggestions"
|
||||
root-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-[#d7dce0] py-1 text-sm"
|
||||
role="listbox"
|
||||
>
|
||||
<button
|
||||
v-for="(tag, index) in props.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="emit('select-tag-suggestion', 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">{{ props.getTagSuggestionMeta(tag) }}</span>
|
||||
</button>
|
||||
</AdminPopoverSurface>
|
||||
</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="props.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="props.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-settings-sidebar__bottom 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]">{{ props.formatStatNumber(props.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]">{{ props.formatStatNumber(props.postWritingStats.characters) }}개 <span class="text-[#8e9cac]">({{ props.formatStatNumber(props.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]">{{ props.postWritingStats.readingMinutes ? `약 ${props.formatStatNumber(props.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]">블록 {{ props.formatStatNumber(props.postWritingStats.blocks) }}개 · 이미지 {{ props.formatStatNumber(props.postWritingStats.images) }}개</span>
|
||||
</div>
|
||||
</section>
|
||||
<button
|
||||
v-if="props.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="props.deleting"
|
||||
@click="emit('delete-post')"
|
||||
>
|
||||
<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>{{ props.deleting ? '삭제 중' : 'Delete post' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AdminEditorBlockPanel
|
||||
:open="props.editorBlockPanel.open"
|
||||
:panel="props.editorBlockPanel.panel"
|
||||
@panel-focus-in="emit('panel-focus-in')"
|
||||
@panel-focus-out="emit('panel-focus-out')"
|
||||
@update-media-image="emit('update-media-image', $event)"
|
||||
@set-media-use-alt="emit('set-media-use-alt', $event)"
|
||||
@move-gallery-image="emit('move-gallery-image', $event)"
|
||||
@remove-media-image="emit('remove-media-image', $event)"
|
||||
@add-gallery-images="emit('add-gallery-images', $event)"
|
||||
@update-embed-url="emit('update-embed-url', $event)"
|
||||
@update-quote-background="emit('update-quote-background', $event)"
|
||||
@update-callout-options="emit('update-callout-options', $event)"
|
||||
@update-code-options="emit('update-code-options', $event)"
|
||||
@update-toggle-options="emit('update-toggle-options', $event)"
|
||||
@update-inline-color="emit('update-inline-color', $event)"
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
@@ -191,7 +191,8 @@
|
||||
| components/admin/AdminAdsSettingsCard.vue | 관리자 사이트 설정의 위치별 Ads 코드 카드(메인 피드·메인 인피드·오른쪽 사이드·게시물 오른쪽 사이드·게시물 본문 상단·인아티클·하단) |
|
||||
| components/admin/AdminPostExportFileRow.vue | 관리자 사이트 설정 내보내기 작업의 분할 파일 선택 행 |
|
||||
| components/admin/AdminMediaVideoThumbnail.vue | 관리자 미디어 목록 비디오 항목의 초반 프레임 캔버스 썸네일 |
|
||||
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(왼쪽 상태 텍스트·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·자동 저장 실패 payload 반복 재시도 차단·자동 저장 실패/진행 중 이탈 차단·신규 임시 슬러그·중복 슬러그 필드 하단 오류 표시·중복 슬러그 중 Publish/Update/최종 저장 비활성화·저장 중 추가 변경 재자동저장·발행 저장 중 이탈 확인·발행·예약·멤버십·비공개 상태 저장, 미저장 비교에서 태그 표시명 보조값 제외, 공개 상태 전환 시 빈 발행 시각 현재 시각 보정, 발행 모달(중앙 배치), 좌우 설정 패널(작은 화면은 오른쪽 고정 오버레이), 오른쪽 `View Post` 링크, 오른쪽 하단 본문 통계(단어·문자·공백·읽기 시간·블록·이미지), 미리보기 emit·미저장 이탈 가드, 자동 생성 썸네일을 숨기는 원본 전용 대표 이미지 선택(서버 페이지네이션·파일명/사용처 검색·번호형 페이지 이동·작은 파생 이미지 우선 미리보기·고정 높이 모달), 대표 이미지 본문 상단 표시 토글, 추천 글 토글, 사이트 설정 기준 태그 최대 개수 제한, 태그 색상 배지 다중 입력·최근 사용 태그 5개 우선 추천·메인 태그 드롭다운·부분 검색 추천, 새 태그 입력명 유지와 `/`→`-` 슬러그 변환 |
|
||||
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(왼쪽 상태 텍스트·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·자동 저장 실패 payload 반복 재시도 차단·자동 저장 실패/진행 중 이탈 차단·신규 임시 슬러그·중복 슬러그 필드 하단 오류 표시·중복 슬러그 중 Publish/Update/최종 저장 비활성화·저장 중 추가 변경 재자동저장·발행 저장 중 이탈 확인·발행·예약·멤버십·비공개 상태 저장, 미저장 비교에서 태그 표시명 보조값 제외, 공개 상태 전환 시 빈 발행 시각 현재 시각 보정, 발행 모달(중앙 배치), 설정 패널 상태와 이벤트 연결, 미리보기 emit·미저장 이탈 가드, 자동 생성 썸네일을 숨기는 원본 전용 대표 이미지 선택(서버 페이지네이션·파일명/사용처 검색·번호형 페이지 이동·작은 파생 이미지 우선 미리보기·고정 높이 모달), 대표 이미지 본문 상단 표시 토글, 추천 글 토글, 사이트 설정 기준 태그 최대 개수 제한, 태그 색상 배지 다중 입력·최근 사용 태그 5개 우선 추천·메인 태그 드롭다운·부분 검색 추천, 새 태그 입력명 유지와 `/`→`-` 슬러그 변환 |
|
||||
| components/admin/AdminPostSettingsSidebar.vue | 관리자 게시물 작성/수정 우측 설정 사이드바(Post URL·상태·발행 시각·요약·태그·대표 이미지 표시·추천 글·검색엔진 노출·본문 통계·삭제 버튼·블록 설정 패널 오버레이) |
|
||||
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 게시글 작성과 같은 전체 화면 에디터·상단 저장 툴바·접이식 오른쪽 설정 패널, 저장 payload 기준 미저장 비교·라우트/브라우저 이탈 확인, 페이지 공개 상태 선택, HTML 문서 기본 모드, 홈 Pages 진열장 노출 메타와 등록된 페이지 섹션 검색형 선택, 빈 본문/`!`+Tab HTML 골격 자동 완성, 항상 보이는 일반 텍스트/HTML 모드 선택, 한글 제목 영문 슬러그 자동 변환, HTML textarea 커서 위치 파일 URL 삽입, HTML 태그 내부 스마트 따옴표 정규화 |
|
||||
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령(`/표` 기본 표 템플릿 포함)·미디어 모달(이미지·갤러리·비디오·오디오·파일, 업로드 탭 기본·첫 순서, 업로드 완료 파일 자동 선택, 라이브러리 탭은 `/admin/api/media?paged=1` 서버 페이지 응답·파일명/사용처 제목 검색·번호형 페이지 이동, 카드·본문·라이트박스·Pages 커버 자동 생성 이미지 숨김, 원본 URL 선택 유지·갤러리 선택 순서 배지·목록 미리보기는 720px 본문 파생 이미지 또는 카드 썸네일 우선, 검색 결과 수와 무관한 고정 높이 패널, 업로드 중 중복 업로드·닫기 차단·로딩 표시), 코드 블록 본문 슬래시 명령 비활성화, 커서 블록 컨텍스트·선택 텍스트 색상 컨텍스트 `block-panel` emit, 에디터 본문 `Cmd/Ctrl+Z` 히스토리와 선택 위치 복원, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 소스·라이브 `Cmd+Shift+K` 줄 삭제, 소스·라이브 `Cmd/Ctrl+K` 링크 삽입, 코드·콜아웃·토글 내부 줄 삭제, 라이브 fenced 블록 현재 닫는 줄 기준 교체, 블록 패널 바깥 클릭·지원 블록 밖 커서 이동 시 닫기·미디어 모달 중 유지, 인용 마지막 줄 아래 방향키 외부 문단 포커스 이동, 인용·콜아웃·코드·토글 선언 줄 옵션 수정, IME 조합 중 블록 패널 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원(코드·콜아웃·토글 선언 줄은 본문 줄로 보정), 라이브 슬래시 명령 후 포커스 복원 지연 |
|
||||
| components/admin/AdminEditorBlockPanel.vue | 게시물 설정 사이드바 오버레이 블록 설정(선택 텍스트 색상, 단일 이미지 대표 너비 셀렉트(전체·갤러리 2장 1칸·갤러리 3장 1칸·갤러리 3장 2칸·직접 입력)·직접 너비·이미지·갤러리·링크·iframe·HTML·인용 배경색·콜아웃 제목·아이콘·배경색·코드·토글), 갤러리 선택 이미지 강조 |
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
- 거대 컴포넌트 책임 분리 진행.
|
||||
- `components/admin/AdminMarkdownEditor.vue`: 미디어 모달, 슬래시 명령, 히스토리·선택 복원, 라이브/소스 전환 로직을 단계적으로 composable 또는 하위 컴포넌트로 분리.
|
||||
- `components/content/ContentMarkdownRenderer.vue`: 파싱·블록 렌더링·라이브 편집 중개·라이트박스·광고 삽입 책임을 분리.
|
||||
- `components/admin/AdminPostForm.vue`: 우측 설정 패널 책임을 분리.
|
||||
- `pages/admin/media/index.vue`: 미디어 목록, 폴더 트리, 상세 모달, 재생성 액션을 작은 단위로 분리.
|
||||
- 미디어 카탈로그 전환 코드 수명 정리.
|
||||
- 운영 `MEDIA_CATALOG_MODE=database` 안정화 후 `shadow`/`filesystem` 런타임 분기 축소 가능 여부 검토.
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.11.43
|
||||
|
||||
- 관리자 게시물 폼의 우측 설정 패널 렌더링을 `AdminPostSettingsSidebar` 컴포넌트로 분리.
|
||||
- Post URL·상태·발행 시각·요약·태그·노출 토글·작성 통계·삭제 버튼 표시 책임을 하위 컴포넌트로 이동.
|
||||
- 게시물 폼은 설정 패널 상태와 태그 입력 ref·블록 패널 이벤트 연결만 담당하도록 정리.
|
||||
|
||||
## v1.11.42
|
||||
|
||||
- 관리자 게시물 폼의 발행·예약·발행취소 상태 판별과 모달 상태를 `useAdminPostPublishState` composable로 분리.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.11.42",
|
||||
"version": "1.11.43",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sori.studio",
|
||||
"version": "1.11.42",
|
||||
"version": "1.11.43",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.11.42",
|
||||
"version": "1.11.43",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
Reference in New Issue
Block a user