Files
Dungeons-Ground/apps/web/components/AppShell.vue
pavel444-byte fdf02446f5
Some checks failed
CI / validate (push) Has been cancelled
CI / validate (pull_request) Failing after 11s
feat(ui): add site context menu
Co-authored-by: multica-agent <github@multica.ai>
2026-08-29 16:56:14 +05:00

137 lines
7.6 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>