글쓰기 태그 제한과 표 기능 추가

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-09 17:10:16 +09:00
parent ed30926250
commit 95d234a625
24 changed files with 560 additions and 54 deletions

View File

@@ -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]">

View File

@@ -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>

View File

@@ -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 - 드래그 이벤트

View File

@@ -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>