From 14ce897bf84cc5670a7f27fb70559b3dd12cbd2f Mon Sep 17 00:00:00 2001 From: zenn Date: Fri, 15 May 2026 17:48:19 +0900 Subject: [PATCH] =?UTF-8?q?v1.2.0:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EA=B8=80=20=EB=AA=A9=EB=A1=9D=C2=B7=EC=8A=AC=EB=9F=AC=EA=B7=B8?= =?UTF-8?q?=C2=B7=EC=98=88=EC=95=BD=20=EC=8B=9C=EA=B0=81=20UX=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 발행일 기준 목록 정렬, 추천 필터·별 표시, 슬러그 자동/수동 구분, 예약 날짜·시간 클릭 영역 수정. Co-authored-by: Cursor --- components/admin/AdminPostForm.vue | 307 ++++++++++++++++------ docs/changelog.md | 4 + docs/spec.md | 4 +- docs/update.md | 7 + package-lock.json | 4 +- package.json | 2 +- pages/admin/posts/index.vue | 65 ++++- server/repositories/content-repository.js | 2 +- 8 files changed, 303 insertions(+), 92 deletions(-) diff --git a/components/admin/AdminPostForm.vue b/components/admin/AdminPostForm.vue index 2eadc81..fd7c439 100644 --- a/components/admin/AdminPostForm.vue +++ b/components/admin/AdminPostForm.vue @@ -42,10 +42,17 @@ const emit = defineEmits(['submit', 'preview', 'delete', 'autosave']) const autosaveStoragePrefix = 'SORI_ADMIN_POST_AUTOSAVE' let draftPersistTimer = null -const slugTouched = ref( - Boolean(props.initialPost.slug) - && !isAdminPostDraftPlaceholderSlug(props.initialPost.slug) -) +const slugTouched = ref((() => { + const post = props.initialPost + if (!post?.id) { + return false + } + const status = post.status === 'private' ? 'draft' : post.status + if (status === 'published') { + return true + } + return Boolean(post.slug) && !isAdminPostDraftPlaceholderSlug(post.slug) +})()) const blockEditor = ref(null) /** 본문 에디터 모드: write | preview */ const editorMode = ref('write') @@ -147,9 +154,100 @@ const form = reactive({ tagsText: props.initialPost.tags?.join(', ') || '' }) +/** + * 서버에 반영된 게시 형태(툴바·자동 저장·슬러그 자동 연동 분기) + * @param {Object} post - 게시물 + * @returns {'draft' | 'publishedLive' | 'scheduled'} + */ +const getPersistedPublishKind = (post) => { + if (!post?.id) { + return 'draft' + } + const st = post.status === 'private' ? 'draft' : post.status + 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 postUrlLabel = computed(() => form.slug || toSlug(form.title) || '') -const postUrlHint = computed(() => props.publicUrl || (postUrlLabel.value ? `/post/${postUrlLabel.value}/` : '/post/')) +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 + } +}) + +/** 저장된 공개 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` 반전, 기본 노출) @@ -221,22 +319,20 @@ const normalizeTagToken = (value) => { .replace(/^-|-$/g, '') } -watch(() => form.title, (title) => { - if (!slugTouched.value) { - const next = toSlug(title) - if (next) { - form.slug = next - } - } -}) - /** - * 슬러그 직접 입력 상태 표시 + * 슬러그 입력 포커스 아웃 시 정규화·자동 모드 복귀 * @returns {void} */ -const touchSlug = () => { - slugTouched.value = true +const commitSlugInput = () => { + if (!slugTouched.value) { + return + } + form.slug = toSlug(form.slug) + + if (!form.slug && persistedPublishKind.value === 'draft' && titleDerivedSlug.value) { + slugTouched.value = false + } } /** @@ -282,43 +378,6 @@ const isDraftLike = computed(() => form.status === 'draft' && !isScheduledPost() /** 발행됨(즉시 공개) */ const isPublishedLive = computed(() => form.status === 'published' && !isScheduledPost()) -/** - * 서버에 반영된 게시 형태(툴바·자동 저장 분기 전용) - * @param {Object} post - 게시물 - * @returns {'draft' | 'publishedLive' | 'scheduled'} - */ -const getPersistedPublishKind = (post) => { - if (!post?.id) { - return 'draft' - } - const st = post.status === 'private' ? 'draft' : post.status - 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 displayPublishedLive = computed(() => persistedPublishKind.value === 'publishedLive' @@ -409,7 +468,7 @@ const createPostPayload = (options = {}) => { return { title: toAdminPostStoredTitle(form.title), - slug: toSlug(form.slug || form.title), + slug: effectiveSlug.value, excerpt: form.excerpt.trim(), content: normalizeMarkdownContent(form.content), featuredImage: form.featuredImage.trim() || null, @@ -439,10 +498,7 @@ const serializedPostFingerprint = computed(() => serializePostPayload()) * 초안 자동 저장을 서버에 보낼 수 있는지(슬러그 유효, 제목은 비어 있으면 서버에서 `(제목 없음)`으로 보정) * @returns {boolean} */ -const canPersistDraftPayload = computed(() => { - const s = toSlug(form.slug || form.title) - return Boolean(s) -}) +const canPersistDraftPayload = computed(() => Boolean(effectiveSlug.value)) /** * 서버 자동 저장 대상: 폼이 초안이고, 서버에도 아직 초안으로만 반영된 글(발행·예약 글의 미저장 사이드바 변경은 제외) @@ -698,6 +754,39 @@ const dropFeaturedImage = async (event) => { 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 - 키보드 이벤트 @@ -1056,9 +1145,9 @@ defineExpose({
@@ -1236,28 +1325,41 @@ defineExpose({
Post URL View Post
-
@@ -1279,11 +1381,17 @@ defineExpose({ 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" > - + @@ -1656,11 +1772,17 @@ defineExpose({ 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" > - + @@ -1745,14 +1875,23 @@ defineExpose({ diff --git a/docs/changelog.md b/docs/changelog.md index 8ffeb82..5296723 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,9 @@ # 업데이트 요약 +## v1.2.0 + +- 관리자 글 목록 정렬·개수·추천 필터·별 표시, 슬러그·예약 시각 UX를 정리했다. + ## v1.1.19 - 관리자 글쓰기 헤더에 작성/미리보기 전환, Update 시 발행일 유지, 미디어 검색. diff --git a/docs/spec.md b/docs/spec.md index 0ce10de..e3aa608 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -447,8 +447,10 @@ components/content/ > 글 발행/초안 전환은 `PUT /admin/api/posts/:id`의 `status`와 `published_at`으로 처리한다. 예약 글은 별도 enum이 아니라 `published`와 미래 시각의 `published_at` 조합이다. > 관리자 글 목록의 삭제 액션은 휴지통 아이콘으로 표시하며, 기본은 낮은 강조로 두고 호버 시에만 색·불투명도를 올린다. -> 관리자 글 목록 상단은 좌측에 제목 블록, 우측에 상태·태그·정렬 필터와 «새 글» 버튼을 한 줄(좁은 화면에서는 줄바꿈)로 두며 필터는 «새 글» 바로 왼쪽에 배치한다. +> 관리자 글 목록 상단은 좌측에 제목·**총 N개**(추천 M개·필터 시 표시 K개) 요약, 우측에 상태·태그·**추천(전체/추천만)**·정렬 필터와 «새 글» 버튼을 한 줄(좁은 화면에서는 줄바꿈)로 두며 필터는 «새 글» 바로 왼쪽에 배치한다. +> 관리자 글 목록 표 첫 열은 `is_featured`(추천 글)일 때만 금색 별 아이콘을 표시한다. 추천 여부는 글 편집 설정의 «추천 글» 토글로 지정한다. > 관리자 글 목록의 날짜 열은 **발행일**(`published_at`, 시·분 포함)이며, `showPostUpdatedAt`이 true이고 발행 후 수정이 있으면 아래에 `수정: …` 보조 줄을 표시한다. +> 관리자 글 목록 기본 정렬(최신순·오래된순)은 **발행일** 기준이며, `published_at`이 없는 초안 등은 **수정일**(`updated_at`)로 대체한다. API(`listAdminPosts`)와 화면 필터 정렬 모두 동일 규칙을 쓴다. > 태그 삭제 시 `post_tags` 연결도 데이터베이스 외래 키 규칙에 따라 함께 삭제된다. > 공개 `GET /api/tags`는 `managed`(메인 태그)만 반환한다. > 관리자 태그 목록 응답은 각 태그의 `postCount`, `lastUsedAt`, `updatedAt`을 포함한다. diff --git a/docs/update.md b/docs/update.md index c40a421..0fee219 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,12 @@ # 업데이트 이력 +## v1.2.0 + +- 관리자 글 목록: 발행일 기준 정렬(`published_at` 우선, 없으면 `updated_at`), 총·추천·필터 표시 개수, 추천만 필터, 추천 글 별(★) 열. +- 관리자 글 슬러그: Post URL 미리보기 즉시 반영·저장 전 안내, 초안은 제목 연동 자동 슬러그(연한 표시), 발행·예약 글은 제목 변경 시 슬러그 고정(중복 409 예방). +- 예약·발행 시각: 달력·KST 클릭 영역 `showPicker` 연동. +- 패키지 버전 `1.2.0`으로 갱신. + ## v1.1.19 - 관리자 글쓰기: 작성/미리보기 토글을 툴바에서 헤더(Update 왼쪽)로 이동, 미리보기 시 툴바 숨김, 미디어 모달 파일명 검색. diff --git a/package-lock.json b/package-lock.json index 9d855e7..a8529eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.1.18", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.1.18", + "version": "1.2.0", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index 1830a00..238b570 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.1.19", + "version": "1.2.0", "private": true, "type": "module", "imports": { diff --git a/pages/admin/posts/index.vue b/pages/admin/posts/index.vue index 8bbe3b9..80c2206 100644 --- a/pages/admin/posts/index.vue +++ b/pages/admin/posts/index.vue @@ -7,6 +7,7 @@ const deletingId = ref('') const errorMessage = ref('') const statusFilter = ref('all') const tagFilter = ref('all') +const featuredFilter = ref('all') const sortOrder = ref('newest') const { data: posts, refresh } = await useFetch('/admin/api/posts', { @@ -47,6 +48,17 @@ const getUpdatedDateLabel = (post) => { return `수정: ${formatPostDateTime(post.updatedAt)}` } +/** + * 목록 정렬용 시각(ms). 발행일이 있으면 발행일, 없으면 수정일(초안 등). + * @param {Object} post - 게시물 + * @returns {number} 정렬 기준 시각 + */ +const getListSortTimestamp = (post) => { + const sortAt = post.publishedAt || post.updatedAt || post.createdAt + + return new Date(sortAt || 0).getTime() +} + /** * 게시물 공개 여부 확인 * @param {Object} post - 게시물 @@ -131,17 +143,30 @@ const usedTagSlugs = computed(() => { return [...slugs].sort((a, b) => getTagName(a).localeCompare(getTagName(b), 'ko')) }) +/** 전체 게시물 수 */ +const totalPostCount = computed(() => posts.value.length) + +/** 추천(`isFeatured`) 게시물 수 */ +const featuredPostCount = computed(() => posts.value.filter((post) => post.isFeatured).length) + +/** 필터가 적용됐는지 */ +const hasActiveListFilters = computed(() => statusFilter.value !== 'all' + || tagFilter.value !== 'all' + || featuredFilter.value !== 'all') + const filteredPosts = computed(() => { const filtered = posts.value.filter((post) => { const matchesStatus = statusFilter.value === 'all' || getPostStatusKey(post) === statusFilter.value const matchesTag = tagFilter.value === 'all' || (post.tags || []).includes(tagFilter.value) + const matchesFeatured = featuredFilter.value === 'all' + || (featuredFilter.value === 'featured' && post.isFeatured) - return matchesStatus && matchesTag + return matchesStatus && matchesTag && matchesFeatured }) return [...filtered].sort((a, b) => { - const left = new Date(a.updatedAt || a.createdAt || 0).getTime() - const right = new Date(b.updatedAt || b.createdAt || 0).getTime() + const left = getListSortTimestamp(a) + const right = getListSortTimestamp(b) return sortOrder.value === 'oldest' ? left - right : right - left }) @@ -183,6 +208,17 @@ const deletePost = async (post) => {

글 목록

+

+ 총 {{ totalPostCount }}개 + + +

@@ -204,6 +240,13 @@ const deletePost = async (post) => { +