Files
sori.studio/components/admin/AdminRealtimePages.vue

153 lines
6.3 KiB
Vue

<script setup>
import { formatAnalyticsCount, getActivePageHref, getActivePageTitle, getRealtimePages } from '~/lib/analytics-display.js'
const props = defineProps({ session: { type: Object, required: true } })
const pages = computed(() => getRealtimePages(props.session))
const triggerRef = ref(null)
const panelRef = ref(null)
const isOpen = ref(false)
const isPinned = ref(false)
const popoverStyle = ref({})
const popoverId = useId()
let closeTimer = null
/**
* 열린 페이지 목록이 화면 안에 표시되도록 위치를 계산한다.
* @returns {void}
*/
const updatePosition = () => {
if (!triggerRef.value || !isOpen.value) return
const rect = triggerRef.value.getBoundingClientRect()
const width = Math.min(480, window.innerWidth - 24)
const maxHeight = Math.min(320, window.innerHeight - 24)
const height = Math.min(panelRef.value?.parentElement?.offsetHeight || maxHeight, maxHeight)
const top = rect.bottom + height + 8 <= window.innerHeight ? rect.bottom + 4 : Math.max(12, rect.top - height - 4)
popoverStyle.value = {
left: `${Math.max(12, Math.min(rect.left, window.innerWidth - width - 12))}px`,
top: `${top}px`, width: `${width}px`, maxHeight: `${maxHeight}px`
}
}
/**
* 목록 닫기 예약을 취소한다.
* @returns {void}
*/
const cancelClose = () => { clearTimeout(closeTimer) }
/**
* 열린 페이지 목록을 표시한다.
* @returns {Promise<void>} 위치 반영
*/
const openPages = async () => {
if (pages.value.length <= 1) return
cancelClose()
isOpen.value = true
await nextTick()
updatePosition()
}
/**
* 고정하지 않은 목록을 포인터 이동 후 닫는다.
* @returns {void}
*/
const scheduleClose = () => {
cancelClose()
closeTimer = setTimeout(() => {
if (!isPinned.value && !panelRef.value?.contains(document.activeElement) && document.activeElement !== triggerRef.value) isOpen.value = false
}, 150)
}
/**
* 클릭이나 Enter로 목록 고정을 전환한다.
* @returns {void}
*/
const togglePinned = () => {
isPinned.value = !isPinned.value
if (isPinned.value) openPages()
else isOpen.value = false
}
/**
* 목록을 닫고 필요하면 트리거로 포커스를 돌린다.
* @param {boolean} [restoreFocus] - 포커스 복원 여부
* @returns {void}
*/
const closePages = (restoreFocus = false) => {
isPinned.value = false
if (restoreFocus) triggerRef.value?.focus()
isOpen.value = false
}
/**
* 목록 바깥 클릭을 처리한다.
* @param {PointerEvent} event - 포인터 이벤트
* @returns {void}
*/
const handleOutside = (event) => {
if (!triggerRef.value?.contains(event.target) && !panelRef.value?.contains(event.target)) closePages()
}
/**
* Escape로 페이지 목록을 닫는다.
* @param {KeyboardEvent} event - 키보드 이벤트
* @returns {void}
*/
const handleKeydown = (event) => {
if (!isOpen.value) return
if (event.key === 'Escape') {
event.preventDefault()
closePages(true)
}
if (event.key === 'ArrowDown' && document.activeElement === triggerRef.value) {
event.preventDefault()
panelRef.value?.querySelector('a, button')?.focus()
}
}
watch(() => pages.value.length, (count) => {
if (count <= 1) closePages()
})
onMounted(() => {
document.addEventListener('pointerdown', handleOutside)
document.addEventListener('keydown', handleKeydown)
window.addEventListener('resize', updatePosition)
window.addEventListener('scroll', updatePosition, true)
})
onBeforeUnmount(() => {
cancelClose()
document.removeEventListener('pointerdown', handleOutside)
document.removeEventListener('keydown', handleKeydown)
window.removeEventListener('resize', updatePosition)
window.removeEventListener('scroll', updatePosition, true)
})
</script>
<template>
<div class="admin-realtime-pages min-w-0">
<button v-if="pages.length > 1" ref="triggerRef" type="button" class="admin-realtime-pages__trigger flex w-full min-w-0 items-center gap-1 text-left text-xs text-muted hover:text-ink" :aria-expanded="isOpen" :aria-controls="popoverId" aria-label="열린 페이지 목록" @mouseenter="openPages" @mouseleave="scheduleClose" @focus="openPages" @blur="scheduleClose" @click="togglePinned">
<span class="admin-realtime-pages__latest min-w-0 truncate">{{ getActivePageTitle(pages[0]) }}</span>
<span v-if="pages.length > 1" class="admin-realtime-pages__extra shrink-0 font-medium text-[#2563eb]">외 <strong class="admin-realtime-pages__extra-number font-bold">{{ formatAnalyticsCount(pages.length - 1) }}</strong>개</span>
</button>
<a v-else-if="getActivePageHref(pages[0])" :href="getActivePageHref(pages[0])" target="_blank" rel="noopener noreferrer" class="admin-realtime-pages__single block truncate text-xs text-muted hover:text-ink hover:underline">{{ getActivePageTitle(pages[0]) }}</a>
<span v-else class="admin-realtime-pages__single block truncate text-xs text-muted">{{ getActivePageTitle(pages[0]) }}</span>
<Teleport to="body">
<AdminPopoverSurface v-if="isOpen && pages.length > 1" :id="popoverId" root-class="admin-realtime-pages__popover admin-layout--light-controls fixed z-[80] overflow-y-auto rounded-lg border-line p-3 text-xs text-[#15171a]" :surface-style="popoverStyle" @mouseenter="cancelClose" @mouseleave="scheduleClose" @focusin="cancelClose" @focusout="scheduleClose">
<div ref="panelRef">
<div class="mb-2 flex items-center justify-between gap-3">
<p class="font-semibold">열린 페이지 {{ formatAnalyticsCount(pages.length) }}개 · 최근 확인순</p>
<button type="button" class="shrink-0 text-muted hover:text-ink" aria-label="열린 페이지 목록 닫기" @click="closePages(true)">닫기</button>
</div>
<p class="mb-2 text-muted">IP {{ session.ipAddress || '-' }}</p>
<ul class="admin-realtime-pages__list divide-y divide-line">
<li v-for="(page, index) in pages" :key="page.key || index" class="py-2">
<a v-if="getActivePageHref(page)" :href="getActivePageHref(page)" target="_blank" rel="noopener noreferrer" class="admin-realtime-pages__link block break-words hover:underline">{{ getActivePageTitle(page) }} <span aria-hidden="true">↗</span><span class="sr-only"> 새 창으로 열기</span></a>
<span v-else class="break-words">{{ getActivePageTitle(page) }}</span>
</li>
</ul>
</div>
</AdminPopoverSurface>
</Teleport>
</div>
</template>