@@ -17,6 +17,12 @@ import {
|
||||
getSocialIconPreset,
|
||||
normalizeSocialLinks
|
||||
} from '~/lib/social-links.js'
|
||||
import {
|
||||
DEFAULT_POST_TAG_LIMIT,
|
||||
MAX_POST_TAG_LIMIT,
|
||||
MIN_POST_TAG_LIMIT,
|
||||
normalizePostTagLimit
|
||||
} from '~/lib/post-tag-limit.js'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'admin'
|
||||
@@ -103,7 +109,8 @@ const socialSnapshot = reactive({
|
||||
})
|
||||
/** 편집 시작 시점의 POST 설정(취소 시 복원용) */
|
||||
const postSnapshot = reactive({
|
||||
showPostUpdatedAt: false
|
||||
showPostUpdatedAt: false,
|
||||
postTagLimit: DEFAULT_POST_TAG_LIMIT
|
||||
})
|
||||
/** 편집 시작 시점의 메인 화면 커버(취소 시 복원용) */
|
||||
const homeCoverSnapshot = reactive({
|
||||
@@ -166,6 +173,7 @@ const form = reactive({
|
||||
copyrightText: settings.value?.copyrightText || '©2026 sori.studio',
|
||||
socialLinks: normalizeSocialLinks(settings.value?.socialLinks || []),
|
||||
showPostUpdatedAt: Boolean(settings.value?.showPostUpdatedAt),
|
||||
postTagLimit: normalizePostTagLimit(settings.value?.postTagLimit),
|
||||
homeCoverImageUrl: settings.value?.homeCoverImageUrl || '',
|
||||
homeCoverDarkImageUrl: settings.value?.homeCoverDarkImageUrl || '',
|
||||
homeCoverTitle: settings.value?.homeCoverTitle || '',
|
||||
@@ -222,7 +230,10 @@ const hasSocialChanges = computed(() => editSocial.value
|
||||
* @returns {boolean} 변경 여부
|
||||
*/
|
||||
const hasPostChanges = computed(() => editPost.value
|
||||
&& form.showPostUpdatedAt !== postSnapshot.showPostUpdatedAt)
|
||||
&& (
|
||||
form.showPostUpdatedAt !== postSnapshot.showPostUpdatedAt
|
||||
|| normalizePostTagLimit(form.postTagLimit) !== normalizePostTagLimit(postSnapshot.postTagLimit)
|
||||
))
|
||||
|
||||
/**
|
||||
* 메인 화면 커버 변경 여부
|
||||
@@ -1122,6 +1133,7 @@ const buildSiteSettingsPayload = () => ({
|
||||
copyrightText: form.copyrightText,
|
||||
socialLinks: normalizeSocialLinks(form.socialLinks),
|
||||
showPostUpdatedAt: Boolean(form.showPostUpdatedAt),
|
||||
postTagLimit: normalizePostTagLimit(form.postTagLimit),
|
||||
homeCoverImageUrl: form.homeCoverImageUrl || '',
|
||||
homeCoverDarkImageUrl: form.homeCoverDarkImageUrl || '',
|
||||
homeCoverTitle: form.homeCoverTitle || '',
|
||||
@@ -1351,6 +1363,8 @@ const saveSocialSection = async () => {
|
||||
*/
|
||||
const beginEditPost = () => {
|
||||
postSnapshot.showPostUpdatedAt = form.showPostUpdatedAt
|
||||
postSnapshot.postTagLimit = normalizePostTagLimit(form.postTagLimit)
|
||||
form.postTagLimit = normalizePostTagLimit(form.postTagLimit)
|
||||
editPost.value = true
|
||||
}
|
||||
|
||||
@@ -1360,6 +1374,7 @@ const beginEditPost = () => {
|
||||
*/
|
||||
const cancelEditPost = () => {
|
||||
form.showPostUpdatedAt = postSnapshot.showPostUpdatedAt
|
||||
form.postTagLimit = normalizePostTagLimit(postSnapshot.postTagLimit)
|
||||
editPost.value = false
|
||||
}
|
||||
|
||||
@@ -1379,6 +1394,8 @@ const savePostSection = async () => {
|
||||
|
||||
if (ok) {
|
||||
postSnapshot.showPostUpdatedAt = form.showPostUpdatedAt
|
||||
postSnapshot.postTagLimit = normalizePostTagLimit(form.postTagLimit)
|
||||
form.postTagLimit = postSnapshot.postTagLimit
|
||||
editPost.value = false
|
||||
}
|
||||
}
|
||||
@@ -2382,6 +2399,7 @@ onBeforeUnmount(() => {
|
||||
class="mr-5 mt-1 text-pretty text-sm leading-relaxed text-[#657080]"
|
||||
>
|
||||
공개 글 상세·관리자 글 목록에서 발행 후 수정이 있었을 때 수정일을 함께 표시합니다.
|
||||
글쓰기에서 선택할 수 있는 태그 최대 개수도 함께 관리합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div class="-mr-1 mt-[-5px] flex shrink-0 items-center justify-start gap-2">
|
||||
@@ -2433,24 +2451,47 @@ onBeforeUnmount(() => {
|
||||
<span class="relative ml-1 size-5 rounded-full bg-[#f4f6f8] shadow transition-transform peer-checked:translate-x-5" aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-5 flex items-center justify-between gap-4 border-t border-[#eceff2] pt-5">
|
||||
<span class="font-bold text-[#15171a]">태그 최대 개수</span>
|
||||
<span class="font-mono text-sm font-semibold text-[#657080]">
|
||||
{{ normalizePostTagLimit(form.postTagLimit) }}개
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label
|
||||
<div
|
||||
v-else
|
||||
class="admin-settings-screen__post-toggle flex items-center justify-between gap-4 border-t border-[#eceff2] pt-5 text-sm"
|
||||
class="grid gap-5 border-t border-[#eceff2] pt-5 text-sm"
|
||||
>
|
||||
<span class="font-bold text-[#15171a]">수정일 표시</span>
|
||||
<span class="admin-settings-screen__post-toggle-control relative inline-flex h-7 w-12 shrink-0 items-center">
|
||||
<label class="admin-settings-screen__post-toggle flex items-center justify-between gap-4">
|
||||
<span class="font-bold text-[#15171a]">수정일 표시</span>
|
||||
<span class="admin-settings-screen__post-toggle-control relative inline-flex h-7 w-12 shrink-0 items-center">
|
||||
<input
|
||||
v-model="form.showPostUpdatedAt"
|
||||
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-settings-screen__field grid gap-2">
|
||||
<span class="font-bold text-[#15171a]">태그 최대 개수</span>
|
||||
<input
|
||||
v-model="form.showPostUpdatedAt"
|
||||
class="peer sr-only"
|
||||
type="checkbox"
|
||||
aria-label="수정일 표시"
|
||||
v-model.number="form.postTagLimit"
|
||||
class="h-10 rounded-md border border-[#dce0e5] bg-white px-3 text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
|
||||
type="number"
|
||||
:min="MIN_POST_TAG_LIMIT"
|
||||
:max="MAX_POST_TAG_LIMIT"
|
||||
step="1"
|
||||
@blur="form.postTagLimit = normalizePostTagLimit(form.postTagLimit)"
|
||||
>
|
||||
<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>
|
||||
<span class="text-xs leading-relaxed text-[#657080]">
|
||||
최소 {{ MIN_POST_TAG_LIMIT }}개, 최대 {{ MAX_POST_TAG_LIMIT }}개까지 설정할 수 있습니다. 기본값은 {{ DEFAULT_POST_TAG_LIMIT }}개입니다.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<h2 class="admin-settings-screen__section-heading z-20 mb-px pt-10 text-2xl font-bold tracking-tight text-[#15171a]">
|
||||
|
||||
@@ -8,6 +8,8 @@ const id = computed(() => String(route.params.id || ''))
|
||||
const saving = ref(false)
|
||||
const deleting = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const { toast, showToast } = useAdminToast()
|
||||
const TAG_TOAST_STORAGE_KEY = 'SORI_ADMIN_TAG_TOAST'
|
||||
|
||||
const { data: tag } = await useFetch(() => `/admin/api/tags/${id.value}`)
|
||||
|
||||
@@ -26,6 +28,7 @@ if (!tag.value) {
|
||||
const saveTag = async (payload) => {
|
||||
saving.value = true
|
||||
errorMessage.value = ''
|
||||
showToast('info', '변경 내용을 저장하는 중입니다.')
|
||||
|
||||
try {
|
||||
const updatedTag = await $fetch(`/admin/api/tags/${id.value}`, {
|
||||
@@ -34,8 +37,10 @@ const saveTag = async (payload) => {
|
||||
})
|
||||
|
||||
tag.value = updatedTag
|
||||
showToast('success', '변경 내용이 저장되었습니다.')
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '태그를 저장하지 못했습니다.'
|
||||
showToast('error', errorMessage.value)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
@@ -52,14 +57,20 @@ const deleteTag = async () => {
|
||||
|
||||
deleting.value = true
|
||||
errorMessage.value = ''
|
||||
showToast('info', '태그를 삭제하는 중입니다.')
|
||||
|
||||
try {
|
||||
await $fetch(`/admin/api/tags/${id.value}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
sessionStorage.setItem(TAG_TOAST_STORAGE_KEY, JSON.stringify({
|
||||
type: 'success',
|
||||
message: '태그가 삭제되었습니다.'
|
||||
}))
|
||||
await navigateTo('/admin/tags')
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '태그를 삭제하지 못했습니다.'
|
||||
showToast('error', errorMessage.value)
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
@@ -89,6 +100,19 @@ const deleteTag = async () => {
|
||||
<p v-if="errorMessage" class="admin-tag-edit__error mb-5 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
<AdminTagForm :initial-tag="tag" submit-label="변경 저장" :saving="saving" @submit="saveTag" />
|
||||
<AdminTagForm :initial-tag="tag" submit-label="변경 저장" :saving="saving" require-changes @submit="saveTag" />
|
||||
|
||||
<div
|
||||
v-if="toast"
|
||||
class="admin-tag-edit__toast fixed right-5 top-5 z-[100] max-w-[min(24rem,calc(100vw-2.5rem))] rounded border px-4 py-3 text-sm font-semibold shadow-lg"
|
||||
:class="{
|
||||
'border-green-200 bg-green-50 text-green-800': toast.type === 'success',
|
||||
'border-red-200 bg-red-50 text-red-800': toast.type === 'error',
|
||||
'border-line bg-white text-ink': toast.type === 'info'
|
||||
}"
|
||||
role="status"
|
||||
>
|
||||
{{ toast.message }}
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -10,6 +10,7 @@ const promotingTagId = ref('')
|
||||
const demotingTagId = ref('')
|
||||
const deletingGeneralTagId = ref('')
|
||||
const toast = ref(null)
|
||||
const TAG_TOAST_STORAGE_KEY = 'SORI_ADMIN_TAG_TOAST'
|
||||
|
||||
const { openMenuId, closeMenu } = useAdminRowMenu()
|
||||
let toastTimer = null
|
||||
@@ -111,6 +112,21 @@ const showToast = (type, message) => {
|
||||
}, 3200)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const storedToast = sessionStorage.getItem(TAG_TOAST_STORAGE_KEY)
|
||||
|
||||
if (!storedToast) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedToast = JSON.parse(storedToast)
|
||||
showToast(parsedToast.type || 'success', parsedToast.message || '처리되었습니다.')
|
||||
} finally {
|
||||
sessionStorage.removeItem(TAG_TOAST_STORAGE_KEY)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 관리용 태그 드래그 시작
|
||||
* @param {DragEvent} event - 드래그 이벤트
|
||||
|
||||
@@ -5,6 +5,8 @@ definePageMeta({
|
||||
|
||||
const saving = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const { toast, showToast } = useAdminToast()
|
||||
const TAG_TOAST_STORAGE_KEY = 'SORI_ADMIN_TAG_TOAST'
|
||||
|
||||
/**
|
||||
* 새 태그 저장
|
||||
@@ -14,16 +16,22 @@ const errorMessage = ref('')
|
||||
const saveTag = async (payload) => {
|
||||
saving.value = true
|
||||
errorMessage.value = ''
|
||||
showToast('info', '태그를 저장하는 중입니다.')
|
||||
|
||||
try {
|
||||
const tag = await $fetch('/admin/api/tags', {
|
||||
await $fetch('/admin/api/tags', {
|
||||
method: 'POST',
|
||||
body: payload
|
||||
})
|
||||
|
||||
await navigateTo(`/admin/tags/${tag.id}`)
|
||||
sessionStorage.setItem(TAG_TOAST_STORAGE_KEY, JSON.stringify({
|
||||
type: 'success',
|
||||
message: '태그가 저장되었습니다.'
|
||||
}))
|
||||
await navigateTo('/admin/tags')
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '태그를 저장하지 못했습니다.'
|
||||
showToast('error', errorMessage.value)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
@@ -44,5 +52,18 @@ const saveTag = async (payload) => {
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
<AdminTagForm submit-label="태그 저장" :saving="saving" @submit="saveTag" />
|
||||
|
||||
<div
|
||||
v-if="toast"
|
||||
class="admin-tag-editor__toast fixed right-5 top-5 z-[100] max-w-[min(24rem,calc(100vw-2.5rem))] rounded border px-4 py-3 text-sm font-semibold shadow-lg"
|
||||
:class="{
|
||||
'border-green-200 bg-green-50 text-green-800': toast.type === 'success',
|
||||
'border-red-200 bg-red-50 text-red-800': toast.type === 'error',
|
||||
'border-line bg-white text-ink': toast.type === 'info'
|
||||
}"
|
||||
role="status"
|
||||
>
|
||||
{{ toast.message }}
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user