게시글 목록 행 메뉴 공통화
This commit is contained in:
@@ -9,7 +9,7 @@
|
|||||||
- `components/admin/AdminPostForm.vue`: 태그 입력, 대표 이미지 선택, 발행/초안 저장 상태, 우측 설정 패널 책임을 분리.
|
- `components/admin/AdminPostForm.vue`: 태그 입력, 대표 이미지 선택, 발행/초안 저장 상태, 우측 설정 패널 책임을 분리.
|
||||||
- `pages/admin/media/index.vue`: 미디어 목록, 폴더 트리, 상세 모달, 재생성 액션을 작은 단위로 분리.
|
- `pages/admin/media/index.vue`: 미디어 목록, 폴더 트리, 상세 모달, 재생성 액션을 작은 단위로 분리.
|
||||||
- 관리자 UI 공통 컴포넌트 정리.
|
- 관리자 UI 공통 컴포넌트 정리.
|
||||||
- 모달 패널, 필터 팝오버, 행 메뉴, 페이지네이션 툴바의 radius·shadow·z-index·색상 규칙을 공통화.
|
- 모달 패널, 필터 팝오버, 페이지네이션 툴바의 radius·shadow·z-index·색상 규칙을 공통화.
|
||||||
- 게시글/페이지/미디어 목록의 검색·필터·정렬 레이아웃을 같은 패턴으로 유지.
|
- 게시글/페이지/미디어 목록의 검색·필터·정렬 레이아웃을 같은 패턴으로 유지.
|
||||||
- 최근 브라우저 회귀 테스트 추가.
|
- 최근 브라우저 회귀 테스트 추가.
|
||||||
- 태그 추천 선택 후 다음 태그 후보가 포커스 해제 없이 유지되는지 확인.
|
- 태그 추천 선택 후 다음 태그 후보가 포커스 해제 없이 유지되는지 확인.
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## v1.11.4
|
||||||
|
|
||||||
|
- 관리자 게시글 목록 행 메뉴를 공용 `AdminRowMoreMenu`와 `useAdminRowMenu` 기준으로 통일.
|
||||||
|
- 게시글 목록의 중복 행 메뉴 위치 계산·Teleport 렌더링 로직 제거.
|
||||||
|
|
||||||
## v1.11.3
|
## v1.11.3
|
||||||
|
|
||||||
- v1.11 내부 개선 TODO 추가.
|
- v1.11 내부 개선 TODO 추가.
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.11.3",
|
"version": "1.11.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.11.3",
|
"version": "1.11.4",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.11.3",
|
"version": "1.11.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"imports": {
|
"imports": {
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ definePageMeta({
|
|||||||
const deletingId = ref('')
|
const deletingId = ref('')
|
||||||
const togglingFeaturedId = ref('')
|
const togglingFeaturedId = ref('')
|
||||||
const togglingPinnedId = ref('')
|
const togglingPinnedId = ref('')
|
||||||
const openPostMenuId = ref('')
|
const { openMenuId: openPostMenuId, closeMenu: closePostMenu } = useAdminRowMenu()
|
||||||
const postMenuStyle = ref({})
|
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const statusFilter = ref('all')
|
const statusFilter = ref('all')
|
||||||
const tagFilter = ref('all')
|
const tagFilter = ref('all')
|
||||||
@@ -31,7 +30,6 @@ const referenceModalData = ref(createDefaultPostReferences())
|
|||||||
const referenceModalLoading = ref(false)
|
const referenceModalLoading = ref(false)
|
||||||
const referenceModalError = ref('')
|
const referenceModalError = ref('')
|
||||||
const referenceModalSearchQuery = ref('')
|
const referenceModalSearchQuery = ref('')
|
||||||
const postMenuTriggerRefs = new Map()
|
|
||||||
const tagFilterRef = ref(null)
|
const tagFilterRef = ref(null)
|
||||||
const postSearchInputRef = ref(null)
|
const postSearchInputRef = ref(null)
|
||||||
|
|
||||||
@@ -715,14 +713,6 @@ const selectAnalyticsModalPeriod = async (period) => {
|
|||||||
await fetchAnalyticsModalData()
|
await fetchAnalyticsModalData()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 열린 행 메뉴를 닫는다.
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
const closePostMenu = () => {
|
|
||||||
openPostMenuId.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 태그 필터 드롭다운을 연다.
|
* 태그 필터 드롭다운을 연다.
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
@@ -826,79 +816,14 @@ const setPostPage = (page) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 게시물 행 메뉴 트리거 엘리먼트를 저장한다.
|
* 외부 클릭 시 태그 필터를 닫는다.
|
||||||
* @param {string} postId - 게시물 ID
|
|
||||||
* @param {Element|null} element - 트리거 엘리먼트
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
const setPostMenuTriggerRef = (postId, element) => {
|
|
||||||
if (!element) {
|
|
||||||
postMenuTriggerRefs.delete(postId)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
postMenuTriggerRefs.set(postId, element)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 게시물 행 메뉴 위치를 화면 기준으로 계산한다.
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
const updatePostMenuPosition = () => {
|
|
||||||
if (!import.meta.client || !openPostMenuId.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const trigger = postMenuTriggerRefs.get(openPostMenuId.value)
|
|
||||||
if (!trigger) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const rect = trigger.getBoundingClientRect()
|
|
||||||
const menuWidth = 176
|
|
||||||
const estimatedHeight = 112
|
|
||||||
const margin = 8
|
|
||||||
const left = Math.max(margin, Math.min(rect.right - menuWidth, window.innerWidth - menuWidth - margin))
|
|
||||||
const opensUp = rect.bottom + estimatedHeight + margin > window.innerHeight
|
|
||||||
const top = opensUp
|
|
||||||
? Math.max(margin, rect.top - estimatedHeight - 4)
|
|
||||||
: rect.bottom + 4
|
|
||||||
|
|
||||||
postMenuStyle.value = {
|
|
||||||
left: `${left}px`,
|
|
||||||
top: `${top}px`,
|
|
||||||
width: `${menuWidth}px`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 행 메뉴 열기/닫기
|
|
||||||
* @param {string} postId - 게시물 ID
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
const togglePostMenu = (postId) => {
|
|
||||||
openPostMenuId.value = openPostMenuId.value === postId ? '' : postId
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 외부 클릭 시 행 메뉴 닫기
|
|
||||||
* @param {PointerEvent} event - 포인터 이벤트
|
* @param {PointerEvent} event - 포인터 이벤트
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
const onPostMenuDocumentPointerDown = (event) => {
|
const onPostListDocumentPointerDown = (event) => {
|
||||||
if (isTagFilterOpen.value && event.target instanceof Node && !tagFilterRef.value?.contains(event.target)) {
|
if (isTagFilterOpen.value && event.target instanceof Node && !tagFilterRef.value?.contains(event.target)) {
|
||||||
closeTagFilter()
|
closeTagFilter()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!openPostMenuId.value || !(event.target instanceof HTMLElement)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.target.closest('[data-admin-post-row-menu]')) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
closePostMenu()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1050,26 +975,13 @@ const deletePost = async (post) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener('pointerdown', onPostMenuDocumentPointerDown)
|
document.addEventListener('pointerdown', onPostListDocumentPointerDown)
|
||||||
document.addEventListener('keydown', onDocumentKeydown)
|
document.addEventListener('keydown', onDocumentKeydown)
|
||||||
window.addEventListener('resize', updatePostMenuPosition)
|
|
||||||
window.addEventListener('scroll', updatePostMenuPosition, true)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
document.removeEventListener('pointerdown', onPostMenuDocumentPointerDown)
|
document.removeEventListener('pointerdown', onPostListDocumentPointerDown)
|
||||||
document.removeEventListener('keydown', onDocumentKeydown)
|
document.removeEventListener('keydown', onDocumentKeydown)
|
||||||
window.removeEventListener('resize', updatePostMenuPosition)
|
|
||||||
window.removeEventListener('scroll', updatePostMenuPosition, true)
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(openPostMenuId, async (postId) => {
|
|
||||||
if (!postId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await nextTick()
|
|
||||||
updatePostMenuPosition()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(tagFilterSearch, () => {
|
watch(tagFilterSearch, () => {
|
||||||
@@ -1385,74 +1297,41 @@ watch(postPageCount, (pageCount) => {
|
|||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
<td class="admin-posts__cell admin-posts__cell-actions relative w-12 px-2 py-4 text-right">
|
<td class="admin-posts__cell admin-posts__cell-actions relative w-12 px-2 py-4 text-right">
|
||||||
<div
|
<AdminRowMoreMenu
|
||||||
class="admin-posts__row-menu relative inline-flex justify-end"
|
v-model:open-menu-id="openPostMenuId"
|
||||||
data-admin-post-row-menu
|
:item-id="post.id"
|
||||||
|
menu-label="게시글 메뉴"
|
||||||
|
:disabled="deletingId === post.id || togglingFeaturedId === post.id || togglingPinnedId === post.id"
|
||||||
|
:busy="deletingId === post.id || togglingFeaturedId === post.id || togglingPinnedId === post.id"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
:ref="(element) => setPostMenuTriggerRef(post.id, element)"
|
class="admin-row-more-menu__item"
|
||||||
class="admin-posts__row-menu-trigger inline-flex size-9 items-center justify-center rounded text-[#394047] transition-colors hover:bg-[#eceff2] focus-visible:outline focus-visible:ring-2 focus-visible:ring-[#8e9cac] focus-visible:ring-offset-1 disabled:pointer-events-none disabled:opacity-40"
|
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="deletingId === post.id || togglingFeaturedId === post.id || togglingPinnedId === post.id"
|
role="menuitem"
|
||||||
:aria-expanded="openPostMenuId === post.id"
|
:disabled="togglingPinnedId === post.id"
|
||||||
aria-haspopup="menu"
|
@click="togglePostPinned(post)"
|
||||||
:aria-label="openPostMenuId === post.id ? '게시글 메뉴 닫기' : '게시글 메뉴'"
|
|
||||||
@click.stop="togglePostMenu(post.id)"
|
|
||||||
>
|
>
|
||||||
<span
|
{{ post.adminPinned ? '고정 해제' : '상단 고정' }}
|
||||||
v-if="deletingId === post.id || togglingFeaturedId === post.id || togglingPinnedId === post.id"
|
|
||||||
class="text-[10px] font-semibold text-muted"
|
|
||||||
aria-hidden="true"
|
|
||||||
>…</span>
|
|
||||||
<svg
|
|
||||||
v-else
|
|
||||||
class="size-6 shrink-0"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 -960 960 960"
|
|
||||||
fill="currentColor"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path d="M480-160q-33 0-56.5-23.5T400-240q0-33 23.5-56.5T480-320q33 0 56.5 23.5T560-240q0 33-23.5 56.5T480-160Zm0-240q-33 0-56.5-23.5T400-480q0-33 23.5-56.5T480-560q33 0 56.5 23.5T560-480q0 33-23.5 56.5T480-400Zm0-240q-33 0-56.5-23.5T400-720q0-33 23.5-56.5T480-800q33 0 56.5 23.5T560-720q0 33-23.5 56.5T480-640Z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
<Teleport to="body">
|
<button
|
||||||
<div
|
class="admin-row-more-menu__item"
|
||||||
v-if="openPostMenuId === post.id"
|
type="button"
|
||||||
class="admin-posts__row-menu-popover fixed z-[80] overflow-hidden rounded-xl border border-[#e2e5e9] bg-white py-2 text-sm text-[#3f4650] shadow-[0_18px_50px_rgba(15,23,42,0.16)]"
|
role="menuitem"
|
||||||
:style="postMenuStyle"
|
:disabled="togglingFeaturedId === post.id"
|
||||||
role="menu"
|
@click="togglePostFeatured(post)"
|
||||||
data-admin-post-row-menu
|
>
|
||||||
>
|
{{ post.isFeatured ? '추천 제거' : '게시글 추천' }}
|
||||||
<button
|
</button>
|
||||||
class="admin-posts__row-menu-item block w-full px-4 py-2.5 text-left hover:bg-[#f3f5f7] disabled:opacity-50"
|
<button
|
||||||
type="button"
|
class="admin-row-more-menu__item admin-row-more-menu__item--danger"
|
||||||
role="menuitem"
|
type="button"
|
||||||
:disabled="togglingPinnedId === post.id"
|
role="menuitem"
|
||||||
@click="togglePostPinned(post)"
|
:disabled="deletingId === post.id"
|
||||||
>
|
@click="deletePost(post)"
|
||||||
{{ post.adminPinned ? '고정 해제' : '상단 고정' }}
|
>
|
||||||
</button>
|
게시글 삭제
|
||||||
<button
|
</button>
|
||||||
class="admin-posts__row-menu-item block w-full px-4 py-2.5 text-left hover:bg-[#f3f5f7] disabled:opacity-50"
|
</AdminRowMoreMenu>
|
||||||
type="button"
|
|
||||||
role="menuitem"
|
|
||||||
:disabled="togglingFeaturedId === post.id"
|
|
||||||
@click="togglePostFeatured(post)"
|
|
||||||
>
|
|
||||||
{{ post.isFeatured ? '추천 제거' : '게시글 추천' }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="admin-posts__row-menu-item block w-full px-4 py-2.5 text-left text-[#c0392b] hover:bg-[#fef2f2] disabled:opacity-50"
|
|
||||||
type="button"
|
|
||||||
role="menuitem"
|
|
||||||
:disabled="deletingId === post.id"
|
|
||||||
@click="deletePost(post)"
|
|
||||||
>
|
|
||||||
게시글 삭제
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Teleport>
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
Reference in New Issue
Block a user