137 lines
7.6 KiB
Vue
137 lines
7.6 KiB
Vue
<script setup lang="ts">
|
||
defineProps<{ section?: string }>()
|
||
const emit = defineEmits<{
|
||
campaignAction: [payload: { action: 'rename' | 'delete'; campaignId: string }]
|
||
}>()
|
||
const { session, restore } = useDngAuth()
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const contextMenu = ref<HTMLElement | null>(null)
|
||
const contextOpen = ref(false)
|
||
const contextX = ref(0)
|
||
const contextY = ref(0)
|
||
const contextCampaignId = ref('')
|
||
const contextCampaignTitle = ref('')
|
||
onMounted(() => void restore())
|
||
|
||
const initials = computed(() => {
|
||
const displayName = session.value?.user.user_metadata?.display_name
|
||
const source = typeof displayName === 'string' && displayName.trim() ? displayName : session.value?.user.email ?? ''
|
||
return source.trim().split(/\s+/).map(part => part[0]).join('').slice(0, 2).toUpperCase() || 'D&G'
|
||
})
|
||
const avatarUrl = computed(() => {
|
||
const value = session.value?.user.user_metadata?.avatar_url
|
||
return typeof value === 'string' && value ? value : ''
|
||
})
|
||
|
||
function closeContextMenu() {
|
||
contextOpen.value = false
|
||
contextCampaignId.value = ''
|
||
contextCampaignTitle.value = ''
|
||
}
|
||
|
||
async function openContextMenu(event: MouseEvent) {
|
||
event.preventDefault()
|
||
const campaign = event.target instanceof Element
|
||
? event.target.closest<HTMLElement>('[data-context-campaign]')
|
||
: null
|
||
contextCampaignId.value = campaign?.dataset.contextCampaign ?? ''
|
||
contextCampaignTitle.value = campaign?.dataset.contextLabel ?? ''
|
||
contextX.value = event.clientX
|
||
contextY.value = event.clientY
|
||
contextOpen.value = true
|
||
|
||
await nextTick()
|
||
const bounds = contextMenu.value?.getBoundingClientRect()
|
||
if (!bounds) return
|
||
contextX.value = Math.max(12, Math.min(contextX.value, window.innerWidth - bounds.width - 12))
|
||
contextY.value = Math.max(12, Math.min(contextY.value, window.innerHeight - bounds.height - 12))
|
||
}
|
||
|
||
function runCampaignAction(action: 'rename' | 'delete') {
|
||
const campaignId = contextCampaignId.value
|
||
closeContextMenu()
|
||
if (campaignId) emit('campaignAction', { action, campaignId })
|
||
}
|
||
|
||
async function openAccountSection(section: 'identity' | 'settings') {
|
||
closeContextMenu()
|
||
await navigateTo({ path: '/profile', hash: `#${section}` })
|
||
}
|
||
|
||
function goBack() {
|
||
closeContextMenu()
|
||
if (window.history.length > 1) router.back()
|
||
else void navigateTo('/dashboard')
|
||
}
|
||
|
||
function closeFromPointer(event: PointerEvent) {
|
||
if (contextOpen.value && event.target instanceof Node && !contextMenu.value?.contains(event.target)) closeContextMenu()
|
||
}
|
||
|
||
function closeFromKeyboard(event: KeyboardEvent) {
|
||
if (event.key === 'Escape') closeContextMenu()
|
||
}
|
||
|
||
onMounted(() => {
|
||
document.addEventListener('pointerdown', closeFromPointer)
|
||
document.addEventListener('keydown', closeFromKeyboard)
|
||
window.addEventListener('resize', closeContextMenu)
|
||
window.addEventListener('scroll', closeContextMenu, true)
|
||
})
|
||
onBeforeUnmount(() => {
|
||
document.removeEventListener('pointerdown', closeFromPointer)
|
||
document.removeEventListener('keydown', closeFromKeyboard)
|
||
window.removeEventListener('resize', closeContextMenu)
|
||
window.removeEventListener('scroll', closeContextMenu, true)
|
||
})
|
||
watch(() => route.fullPath, closeContextMenu)
|
||
</script>
|
||
|
||
<template>
|
||
<div class="shell" @contextmenu="openContextMenu">
|
||
<header class="topbar">
|
||
<AppMark />
|
||
<div class="topbar-center"><span class="status-dot" /> PRIVATE ALPHA <b v-if="section">/ {{ section }}</b></div>
|
||
<div class="topbar-actions">
|
||
<NuxtLink to="/dashboard" class="icon-link" aria-label="Dashboard">⌂</NuxtLink>
|
||
<NuxtLink v-if="session" to="/profile" class="avatar" :title="`Open profile for ${session.user.email ?? ''}`" aria-label="Open profile">
|
||
<span>{{ initials }}</span><img v-if="avatarUrl" :src="avatarUrl" alt="">
|
||
</NuxtLink>
|
||
<div v-else class="avatar">D&G</div>
|
||
</div>
|
||
</header>
|
||
<main><slot /></main>
|
||
<Teleport to="body">
|
||
<nav
|
||
v-if="contextOpen"
|
||
ref="contextMenu"
|
||
class="context-menu"
|
||
:style="{ left: `${contextX}px`, top: `${contextY}px` }"
|
||
aria-label="Site actions"
|
||
role="menu"
|
||
@contextmenu.prevent
|
||
>
|
||
<header>
|
||
<small>{{ contextCampaignId ? 'UNIVERSE CONTROL' : 'D&G COMMANDS' }}</small>
|
||
<strong>{{ contextCampaignTitle || 'QUICK ACCESS' }}</strong>
|
||
</header>
|
||
<div v-if="contextCampaignId" class="context-group">
|
||
<button role="menuitem" @click="runCampaignAction('rename')"><span>✎</span> RENAME UNIVERSE</button>
|
||
<button class="danger" role="menuitem" @click="runCampaignAction('delete')"><span>×</span> DELETE UNIVERSE</button>
|
||
</div>
|
||
<div class="context-group">
|
||
<button role="menuitem" @click="openAccountSection('identity')"><span>◎</span> EDIT PROFILE</button>
|
||
<button role="menuitem" @click="goBack"><span>←</span> PREVIOUS PAGE</button>
|
||
<button role="menuitem" @click="openAccountSection('settings')"><span>⚙</span> SETTINGS</button>
|
||
</div>
|
||
<footer><i /> PRIVATE ALPHA / SECURE</footer>
|
||
</nav>
|
||
</Teleport>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.shell{min-height:100vh}.topbar{position:sticky;top:0;z-index:20;height:76px;padding:0 clamp(18px,4vw,64px);display:grid;grid-template-columns:1fr auto 1fr;align-items:center;border-bottom:1px solid var(--line);background:rgba(10,10,10,.9);backdrop-filter:blur(18px)}.topbar-center{font:500 10px/1 var(--mono);color:var(--muted);letter-spacing:.18em}.topbar-center b{color:var(--ink);font-weight:500}.status-dot{display:inline-block;width:6px;height:6px;margin-right:8px;border-radius:50%;background:var(--acid);box-shadow:0 0 12px var(--acid)}.topbar-actions{justify-self:end;display:flex;align-items:center;gap:14px}.icon-link{display:grid;place-items:center;width:36px;height:36px;border:1px solid var(--line);color:var(--muted);text-decoration:none}.avatar{position:relative;overflow:hidden;display:grid;place-items:center;width:36px;height:36px;padding:0;border:0;border-radius:50%;background:var(--acid);color:#0a0a0a;text-decoration:none;font:700 10px var(--mono)}.avatar img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}.avatar span{position:relative}.context-menu{position:fixed;z-index:2000;width:min(286px,calc(100vw - 24px));border:1px solid #3c3c37;background:rgba(10,10,9,.98);color:var(--ink);box-shadow:0 24px 70px rgba(0,0,0,.68);backdrop-filter:blur(18px)}.context-menu header{display:grid;gap:7px;padding:17px 18px 15px;border-bottom:1px solid var(--line);background:linear-gradient(120deg,rgba(217,247,95,.08),transparent 70%)}.context-menu header small{color:var(--acid);font:600 7px var(--mono);letter-spacing:.17em}.context-menu header strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:600 12px var(--display);letter-spacing:-.02em}.context-group{padding:6px;border-bottom:1px solid var(--line)}.context-group button{width:100%;min-height:40px;display:grid;grid-template-columns:28px 1fr;align-items:center;padding:0 10px;border:0;background:transparent;color:#c4c4bd;text-align:left;font:600 8px var(--mono);letter-spacing:.11em}.context-group button span{color:var(--acid);font:500 15px var(--mono)}.context-group button:hover,.context-group button:focus-visible{background:#171715;color:var(--ink);filter:none}.context-group button.danger{color:#e5aaa0}.context-group button.danger span{color:#ff8875}.context-menu footer{padding:11px 15px;color:#6e6e68;font:500 6px var(--mono);letter-spacing:.14em}.context-menu footer i{display:inline-block;width:5px;height:5px;margin-right:7px;border-radius:50%;background:var(--acid);box-shadow:0 0 8px var(--acid)}@media(max-width:700px){.topbar{grid-template-columns:1fr auto}.topbar-center{display:none}}
|
||
</style>
|