1 Commits

Author SHA1 Message Date
59a50a0c97 태그 관리 자동 저장 정리 2026-05-15 11:21:57 +09:00
9 changed files with 67 additions and 35 deletions

View File

@@ -1,5 +1,10 @@
# 업데이트 요약 # 업데이트 요약
## v1.1.8
- 로고·파비콘 파일명 접미사를 년월+랜덤 문자열로 줄임.
- 태그 추가 버튼을 일반 태그 영역으로 옮기고, 메인 태그 순서는 드래그 후 자동 저장되도록 개선.
## v1.1.7 ## v1.1.7
- 사이트 로고와 파비콘을 교체할 때 새 고유 URL로 저장해 운영 환경에서 이전 이미지가 캐시에 남는 문제를 줄임. - 사이트 로고와 파비콘을 교체할 때 새 고유 URL로 저장해 운영 환경에서 이전 이미지가 캐시에 남는 문제를 줄임.

View File

@@ -1,5 +1,11 @@
# 의사결정 이력 # 의사결정 이력
## 2026-05-15 v1.1.8
### 태그 순서 저장을 드롭 즉시 자동화
메인 태그 정렬은 드래그 자체가 명확한 저장 의도를 가진 조작이므로 별도의 `정렬 저장` 버튼을 두면 화면의 책임이 나뉘어 보인다. 태그 추가 버튼도 화면 전체 제목 옆에 있으면 메인 태그 추가처럼 보일 수 있어, 새 태그가 기본적으로 일반 태그로 생성되는 현재 구조에 맞춰 일반 태그 섹션 헤더 오른쪽으로 옮겼다. 순서 저장 중에는 추가 드래그를 잠시 막아 서버 순서와 화면 순서가 어긋나지 않게 한다.
## 2026-05-15 v1.1.7 ## 2026-05-15 v1.1.7
### 사이트 로고 파일명을 교체마다 고유하게 저장 ### 사이트 로고 파일명을 교체마다 고유하게 저장

View File

@@ -116,7 +116,7 @@
| pages/admin/pages/[id].vue | 페이지 수정, 저장/삭제 토스트 | | pages/admin/pages/[id].vue | 페이지 수정, 저장/삭제 토스트 |
| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/썸네일** 탭, 검색은 파일명·게시물/페이지 사용처 제목만, 라이브러리: 폴더 트리·드래그 이동 등, 현재 사이트 설정 로고·파비콘은 사용 중 파일로 잠금, 썸네일: 미참조 파일 삭제·이름 변경 가능, 상세 모달(연결 회원·폴더 편집·**다운로드**) | | pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/썸네일** 탭, 검색은 파일명·게시물/페이지 사용처 제목만, 라이브러리: 폴더 트리·드래그 이동 등, 현재 사이트 설정 로고·파비콘은 사용 중 파일로 잠금, 썸네일: 미참조 파일 삭제·이름 변경 가능, 상세 모달(연결 회원·폴더 편집·**다운로드**) |
| pages/admin/navigation/index.vue | 메뉴 관리: 상단/하단 탭, 테이블+행 드래그(태그 메인과 동일 톤), `useAdminToast` | | pages/admin/navigation/index.vue | 메뉴 관리: 상단/하단 탭, 테이블+행 드래그(태그 메인과 동일 톤), `useAdminToast` |
| pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬·일반 강등, 일반 태그 검색/메인 전환/삭제), 액션 피드백 토스트, 순서 변경 시에만 정렬 저장 활성 | | pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬 자동 저장·일반 강등, 일반 태그 검색/메인 전환/삭제, 일반 태그 헤더의 태그 추가 버튼), 액션 피드백 토스트 |
| pages/admin/tags/new.vue | 태그 생성 | | pages/admin/tags/new.vue | 태그 생성 |
| pages/admin/tags/[id].vue | 태그 수정 | | pages/admin/tags/[id].vue | 태그 수정 |
| pages/admin/settings/index.vue | 사이트 설정(이름, 설명, URL, 1:1 로고 업로드·고유 URL 파비콘 생성, 저작권 문구) | | pages/admin/settings/index.vue | 사이트 설정(이름, 설명, URL, 1:1 로고 업로드·고유 URL 파비콘 생성, 저작권 문구) |
@@ -201,7 +201,7 @@
| server/routes/admin/api/tags/reorder.put.js | 관리자 메인 태그 순서 일괄 저장 API | | server/routes/admin/api/tags/reorder.put.js | 관리자 메인 태그 순서 일괄 저장 API |
| server/routes/admin/api/settings.get.js | 관리자 사이트 설정 조회 API | | server/routes/admin/api/settings.get.js | 관리자 사이트 설정 조회 API |
| server/routes/admin/api/settings.put.js | 관리자 사이트 설정 수정 API | | server/routes/admin/api/settings.put.js | 관리자 사이트 설정 수정 API |
| server/routes/admin/api/settings/logo.post.js | 관리자 사이트 로고 업로드 API(`/uploads/system/logo-*.webp`, `/uploads/system/favicon-*.png` 생성, `시스템` 미디어 메타 저장) | | server/routes/admin/api/settings/logo.post.js | 관리자 사이트 로고 업로드 API(`/uploads/system/logo-YYYYMM-*.webp`, `/uploads/system/favicon-YYYYMM-*.png` 생성, `시스템` 미디어 메타 저장) |
| server/routes/admin/api/navigation.get.js | 관리자 네비게이션 목록 API | | server/routes/admin/api/navigation.get.js | 관리자 네비게이션 목록 API |
| server/routes/admin/api/navigation.put.js | 관리자 네비게이션 일괄 저장 API | | server/routes/admin/api/navigation.put.js | 관리자 네비게이션 일괄 저장 API |
| server/routes/admin/api/members.get.js | 관리자 멤버 목록 API | | server/routes/admin/api/members.get.js | 관리자 멤버 목록 API |

View File

@@ -445,8 +445,8 @@ components/content/
> 공개 `GET /api/tags`는 `managed`(메인 태그)만 반환한다. > 공개 `GET /api/tags`는 `managed`(메인 태그)만 반환한다.
> 관리자 태그 목록 응답은 각 태그의 `postCount`, `lastUsedAt`, `updatedAt`을 포함한다. > 관리자 태그 목록 응답은 각 태그의 `postCount`, `lastUsedAt`, `updatedAt`을 포함한다.
> 관리자 태그 목록은 `managed` 우선, `sort_order ASC, 최근 사용/수정 DESC, name ASC` 기준으로 정렬한다. > 관리자 태그 목록은 `managed` 우선, `sort_order ASC, 최근 사용/수정 DESC, name ASC` 기준으로 정렬한다.
> 메인 태그 순서 저장은 드래그 순서를 받아 `sort_order`를 순차 값으로 다시 저장한다. > 메인 태그 순서 저장은 드래그 드롭 직후 자동으로 실행되며, 드래그 순서를 받아 `sort_order`를 순차 값으로 다시 저장한다.
> 메인 태그 순서가 서버에서 불러온 순서와 같을 때는 `정렬 저장` 버튼이 비활성화된다. > 메인 태그 순서 저장 중에는 추가 드래그를 잠시 막고 저장 상태를 표시한다.
> 관리자 태그 추가 화면에서 직접 생성한 태그는 기본적으로 `general`(일반 태그)로 생성하고, 태그 관리 화면의 일반 태그 목록에 바로 표시한다. > 관리자 태그 추가 화면에서 직접 생성한 태그는 기본적으로 `general`(일반 태그)로 생성하고, 태그 관리 화면의 일반 태그 목록에 바로 표시한다.
> 게시물 작성에서 새로 생기는 태그는 기본적으로 `general`(일반 태그)로 생성한다. > 게시물 작성에서 새로 생기는 태그는 기본적으로 `general`(일반 태그)로 생성한다.
> 메인 태그는 목록에서 `일반 태그로 변경` 액션으로 강등하며, 일반 태그는 배지형 전체 목록에서 확인·필터·최근 사용순·많이 사용순·이름순 정렬·메인 전환·삭제를 수행한다. > 메인 태그는 목록에서 `일반 태그로 변경` 액션으로 강등하며, 일반 태그는 배지형 전체 목록에서 확인·필터·최근 사용순·많이 사용순·이름순 정렬·메인 전환·삭제를 수행한다.
@@ -550,7 +550,7 @@ components/content/
- 사이트 설정은 `site_settings` 테이블의 단일 레코드로 관리한다. - 사이트 설정은 `site_settings` 테이블의 단일 레코드로 관리한다.
- 관리자는 사이트 이름, 설명, 사이트 URL, 로고 이미지, 저작권 문구를 수정할 수 있다. - 관리자는 사이트 이름, 설명, 사이트 URL, 로고 이미지, 저작권 문구를 수정할 수 있다.
- 로고 이미지는 1:1 비율로 저장하며 `/admin/api/settings/logo` 업로드 시 `/uploads/system/logo-*.webp``/uploads/system/favicon-*.png`를 고유 파일명으로 함께 생성한다. 같은 URL 덮어쓰기로 인한 브라우저·운영 정적 캐시 문제를 피하기 위해 로고 교체마다 새 URL을 저장한다. - 로고 이미지는 1:1 비율로 저장하며 `/admin/api/settings/logo` 업로드 시 `/uploads/system/logo-YYYYMM-random.webp``/uploads/system/favicon-YYYYMM-random.png`를 고유 파일명으로 함께 생성한다. 같은 URL 덮어쓰기로 인한 브라우저·운영 정적 캐시 문제를 피하기 위해 로고 교체마다 새 URL을 저장한다.
- 공개 헤더와 오른쪽 사이드바는 공개 사이트 설정 API 값을 사용한다. - 공개 헤더와 오른쪽 사이드바는 공개 사이트 설정 API 값을 사용한다.
- 공개 헤더와 오른쪽 사이드바는 `logo_url`이 있으면 이미지 로고를 표시하고, 없으면 `logo_text` fallback을 쓴다. `favicon_url`은 head의 icon 링크로 연결한다. - 공개 헤더와 오른쪽 사이드바는 `logo_url`이 있으면 이미지 로고를 표시하고, 없으면 `logo_text` fallback을 쓴다. `favicon_url`은 head의 icon 링크로 연결한다.
- DB 연결이 없는 환경에서는 환경 변수와 기본값 기반 설정을 사용한다. - DB 연결이 없는 환경에서는 환경 변수와 기본값 기반 설정을 사용한다.
@@ -612,8 +612,8 @@ components/content/
/uploads/posts/YYYY/MM/filename.webp /uploads/posts/YYYY/MM/filename.webp
/uploads/pages/YYYY/MM/filename.webp /uploads/pages/YYYY/MM/filename.webp
/uploads/members/avatars/YYYY/MM/filename.webp /uploads/members/avatars/YYYY/MM/filename.webp
/uploads/system/logo-YYYYMMDDTHHMMSS-random.webp /uploads/system/logo-YYYYMM-random.webp
/uploads/system/favicon-YYYYMMDDTHHMMSS-random.png /uploads/system/favicon-YYYYMM-random.png
``` ```
- 관리자 에디터 이미지 업로드 API는 디스크상 `public/uploads/posts/YYYY/MM/`에 저장하지만, DB가 있을 때 `media_metadata`에는 논리 폴더 **`미분류`**로 기록한다. 메타가 없을 때도 서버 목록에서는 `posts/...` 경로를 **`미분류`**로 표시한다(디스크 1단계 `posts`와 이중 표기하지 않음). 저장 파일명은 업로드 원본 파일명(안전 문자만 유지)을 우선 쓰고, 같은 월 디렉터리에 동일 이름이 있으면 `이름-2`, `이름-3` 식으로 넘버링한다. - 관리자 에디터 이미지 업로드 API는 디스크상 `public/uploads/posts/YYYY/MM/`에 저장하지만, DB가 있을 때 `media_metadata`에는 논리 폴더 **`미분류`**로 기록한다. 메타가 없을 때도 서버 목록에서는 `posts/...` 경로를 **`미분류`**로 표시한다(디스크 1단계 `posts`와 이중 표기하지 않음). 저장 파일명은 업로드 원본 파일명(안전 문자만 유지)을 우선 쓰고, 같은 월 디렉터리에 동일 이름이 있으면 `이름-2`, `이름-3` 식으로 넘버링한다.

View File

@@ -1,5 +1,13 @@
# 업데이트 이력 # 업데이트 이력
## v1.1.8
- 사이트 로고·파비콘 고유 파일명 접미사를 년월+랜덤 문자열 형식으로 간소화.
- 관리자 태그 관리 화면의 `태그 추가` 버튼을 일반 태그 섹션 헤더 오른쪽으로 이동.
- 메인 태그 `정렬 저장` 버튼을 제거하고 드래그 드롭 직후 자동 저장되도록 수정.
- 메인 태그 순서 자동 저장 중 추가 드래그를 막고 저장 상태를 표시하도록 정리.
- 패키지 버전 `1.1.8`로 갱신.
## v1.1.7 ## v1.1.7
- 사이트 로고 업로드가 고정 `/uploads/system/logo.webp` 덮어쓰기 대신 고유 파일명 URL을 저장하도록 수정. - 사이트 로고 업로드가 고정 `/uploads/system/logo.webp` 덮어쓰기 대신 고유 파일명 URL을 저장하도록 수정.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "sori.studio", "name": "sori.studio",
"version": "1.1.7", "version": "1.1.8",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sori.studio", "name": "sori.studio",
"version": "1.1.7", "version": "1.1.8",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "sori.studio", "name": "sori.studio",
"version": "1.1.7", "version": "1.1.8",
"private": true, "private": true,
"type": "module", "type": "module",
"imports": { "imports": {

View File

@@ -54,7 +54,7 @@ const filteredGeneralTags = computed(() => {
) )
}) })
/** 서버 기준 메인 태그 id 순서(정렬 저장 버튼 활성 비교용) */ /** 서버 기준 메인 태그 id 순서(자동 저장 필요 여부 비교용) */
const baselineManagedTagIds = ref([]) const baselineManagedTagIds = ref([])
/** /**
@@ -77,7 +77,7 @@ const refreshTagsFromServer = async () => {
resetManagedOrderBaseline() resetManagedOrderBaseline()
/** /**
* 메인 태그 드래그 순서가 기준선과 다른지 여부 * 메인 태그 드래그 순서가 서버 기준선과 다른지 여부
* @returns {boolean} 변경 여부 * @returns {boolean} 변경 여부
*/ */
const isManagedOrderDirty = computed(() => { const isManagedOrderDirty = computed(() => {
@@ -116,6 +116,11 @@ const showToast = (type, message) => {
* @returns {void} * @returns {void}
*/ */
const handleDragStart = (event, tagId) => { const handleDragStart = (event, tagId) => {
if (savingOrder.value) {
event.preventDefault()
return
}
if (!event.dataTransfer) { if (!event.dataTransfer) {
return return
} }
@@ -130,6 +135,10 @@ const handleDragStart = (event, tagId) => {
* @returns {void} * @returns {void}
*/ */
const handleDragOver = (event, tagId) => { const handleDragOver = (event, tagId) => {
if (savingOrder.value) {
return
}
event.preventDefault() event.preventDefault()
dragOverTagId.value = tagId dragOverTagId.value = tagId
} }
@@ -172,15 +181,17 @@ const moveManagedTag = (sourceId, targetId) => {
* 관리용 태그 드롭 처리 * 관리용 태그 드롭 처리
* @param {DragEvent} event - 드래그 이벤트 * @param {DragEvent} event - 드래그 이벤트
* @param {string} targetId - 대상 태그 ID * @param {string} targetId - 대상 태그 ID
* @returns {void} * @returns {Promise<void>}
*/ */
const handleDrop = (event, targetId) => { const handleDrop = async (event, targetId) => {
event.preventDefault() event.preventDefault()
if (!draggingTagId.value) { if (!draggingTagId.value || savingOrder.value) {
return return
} }
moveManagedTag(draggingTagId.value, targetId) moveManagedTag(draggingTagId.value, targetId)
handleDragEnd() handleDragEnd()
await nextTick()
await saveManagedOrder()
} }
/** /**
@@ -204,9 +215,10 @@ const saveManagedOrder = async () => {
tags.value = [...reordered] tags.value = [...reordered]
await refreshTagsFromServer() await refreshTagsFromServer()
showToast('success', '메인 태그 순서가 저장되었습니다.') showToast('success', '메인 태그 순서가 자동 저장되었습니다.')
} catch (error) { } catch (error) {
showToast('error', error?.data?.message || '정렬 순서를 저장하지 못했습니다.') showToast('error', error?.data?.message || '정렬 순서를 저장하지 못했습니다.')
await refreshTagsFromServer()
} finally { } finally {
savingOrder.value = false savingOrder.value = false
} }
@@ -328,7 +340,7 @@ onBeforeUnmount(() => {
<template> <template>
<section class="admin-tags bg-paper p-6"> <section class="admin-tags bg-paper p-6">
<div class="admin-tags__header flex items-center justify-between gap-4"> <div class="admin-tags__header">
<div> <div>
<p class="admin-tags__eyebrow text-xs font-semibold uppercase text-muted"> <p class="admin-tags__eyebrow text-xs font-semibold uppercase text-muted">
Tags Tags
@@ -337,9 +349,6 @@ onBeforeUnmount(() => {
태그 관리 태그 관리
</h1> </h1>
</div> </div>
<NuxtLink class="admin-tags__new rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white" to="/admin/tags/new">
태그 추가
</NuxtLink>
</div> </div>
<p class="mt-3 text-xs text-muted"> <p class="mt-3 text-xs text-muted">
메인 태그는 홈페이지 카테고리 영역에서 사용되며 드래그 정렬이 가능합니다. 일반 태그는 아래 목록에서 확인하고 필요할 메인 태그로 전환할 있습니다. 메인 태그는 홈페이지 카테고리 영역에서 사용되며 드래그 정렬이 가능합니다. 일반 태그는 아래 목록에서 확인하고 필요할 메인 태그로 전환할 있습니다.
@@ -348,14 +357,10 @@ onBeforeUnmount(() => {
<div class="admin-tags__table mt-6 overflow-hidden border border-line"> <div class="admin-tags__table mt-6 overflow-hidden border border-line">
<div class="flex items-center justify-between border-b border-line bg-[#f7f7f5] px-4 py-2.5"> <div class="flex items-center justify-between border-b border-line bg-[#f7f7f5] px-4 py-2.5">
<p class="text-xs font-semibold uppercase text-muted">메인 태그</p> <p class="text-xs font-semibold uppercase text-muted">메인 태그</p>
<button <span v-if="savingOrder" class="inline-flex items-center gap-2 text-xs font-semibold text-muted">
class="rounded border border-line bg-white px-3 py-1.5 text-xs font-semibold disabled:opacity-50" <span class="size-3 animate-spin rounded-full border-2 border-line border-t-[#15171a]" />
type="button" 저장
:disabled="savingOrder || managedTags.length === 0 || !isManagedOrderDirty" </span>
@click="saveManagedOrder"
>
{{ savingOrder ? '저장 중' : '정렬 저장' }}
</button>
</div> </div>
<table class="admin-tags__table-inner w-full border-collapse text-left text-sm"> <table class="admin-tags__table-inner w-full border-collapse text-left text-sm">
<thead class="admin-tags__table-head bg-[#f5f5f2] text-xs uppercase text-muted"> <thead class="admin-tags__table-head bg-[#f5f5f2] text-xs uppercase text-muted">
@@ -372,12 +377,13 @@ onBeforeUnmount(() => {
<tr <tr
v-for="(tag, index) in managedTags" v-for="(tag, index) in managedTags"
:key="tag.id" :key="tag.id"
class="admin-tags__row cursor-move" class="admin-tags__row"
:class="[ :class="[
dragOverTagId === tag.id ? 'bg-[#f9f9f7]' : '', dragOverTagId === tag.id ? 'bg-[#f9f9f7]' : '',
draggingTagId === tag.id ? 'opacity-50' : '' draggingTagId === tag.id ? 'opacity-50' : '',
savingOrder ? 'cursor-not-allowed opacity-60' : 'cursor-move'
]" ]"
draggable="true" :draggable="!savingOrder"
@dragstart="handleDragStart($event, tag.id)" @dragstart="handleDragStart($event, tag.id)"
@dragover="handleDragOver($event, tag.id)" @dragover="handleDragOver($event, tag.id)"
@drop="handleDrop($event, tag.id)" @drop="handleDrop($event, tag.id)"
@@ -422,8 +428,11 @@ onBeforeUnmount(() => {
</div> </div>
<div class="admin-tags__table mt-8 overflow-hidden border border-line"> <div class="admin-tags__table mt-8 overflow-hidden border border-line">
<div class="border-b border-line bg-[#f7f7f5] px-4 py-2.5"> <div class="flex items-center justify-between gap-3 border-b border-line bg-[#f7f7f5] px-4 py-2.5">
<p class="text-xs font-semibold uppercase text-muted">일반 태그</p> <p class="text-xs font-semibold uppercase text-muted">일반 태그</p>
<NuxtLink class="admin-tags__new rounded bg-[#15171a] px-3 py-1.5 text-xs font-semibold text-white" to="/admin/tags/new">
태그 추가
</NuxtLink>
</div> </div>
<div class="space-y-3 bg-white p-4"> <div class="space-y-3 bg-white p-4">
<div class="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between"> <div class="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">

View File

@@ -35,9 +35,13 @@ const clampNumber = (value, minimum, maximum) => {
* 시스템 로고 파일명에 사용할 짧은 고유 접미사를 만든다. * 시스템 로고 파일명에 사용할 짧은 고유 접미사를 만든다.
* @returns {string} 파일명 접미사 * @returns {string} 파일명 접미사
*/ */
const createSystemAssetSuffix = () => `${new Date().toISOString() const createSystemAssetSuffix = () => {
.replace(/[-:]/g, '') const now = new Date()
.replace(/\.\d{3}Z$/g, '')}-${Math.random().toString(36).slice(2, 8)}` const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
return `${year}${month}-${Math.random().toString(36).slice(2, 8)}`
}
/** /**
* 사이트 로고 업로드 API * 사이트 로고 업로드 API