c71e782dc0
- CommandPalette + useCommandPalette: ⌘K opens a search-and-jump panel over real tenants/partners + fixture flags + nav + actions. Arrow keys + Enter navigate, Escape/backdrop close. Recents are intentionally omitted for now; add when there's something to recent over. - Impersonation stub: useImpersonation + ImpersonationModal + ImpersonationBanner. Modal opens from tenant detail and from the palette. Banner stays at the top of the shell until exited. No real OBO token is minted — wiring OAuth Token Exchange is tracked as a follow-up. - IncidentModal + useIncidentModal: opened from the Overview and Infrastructure incident banners, renders the mock INCIDENT data with metrics, timeline and draft composer. - TweaksPanel + useTweaks: floating bottom-right panel for theme (dark/light), density (comfy/compact), env badge (prod/staging/dev). Saved to localStorage. - Theme/density apply via [data-theme] + [data-density] overrides in tokens.css. Topbar env badge now reads from useTweaks instead of a prop. - Layout wires ⌘K + ⌘[ at the document level and mounts the palette + modals + banner + tweaks panel once for all pages.
367 lines
10 KiB
Vue
367 lines
10 KiB
Vue
<script setup lang="ts">
|
|
import type { IconName } from './UiIcon.vue'
|
|
import type { Tenant } from '~/types/tenant'
|
|
import type { Partner } from '~/types/partner'
|
|
import { FLAGS, INCIDENT } from '~/data/fixtures'
|
|
|
|
// Each row in the palette. `action` decides what happens on Enter / click.
|
|
// We try `to` first (a navigateTo) since most rows are navigation; `run` is
|
|
// the escape hatch for actions that aren't a route (open modal, toast, etc.).
|
|
interface Row {
|
|
id: string
|
|
groupLabel: string
|
|
title: string
|
|
subtitle?: string
|
|
icon?: IconName
|
|
badge?: string
|
|
to?: string
|
|
run?: () => void
|
|
}
|
|
|
|
const { isOpen, close } = useCommandPalette()
|
|
const { open: openImpersonation } = useImpersonation()
|
|
const { open: openIncident } = useIncidentModal()
|
|
|
|
const query = ref('')
|
|
const cursor = ref(0)
|
|
const inputRef = ref<HTMLInputElement | null>(null)
|
|
|
|
// Only fetch when the palette has been opened at least once, then refresh on
|
|
// every open so newly-created tenants/partners show up. SSR is disabled (the
|
|
// palette is client-only).
|
|
const { data: tenants, refresh: rT } = useLazyFetch<Tenant[]>('/api/tenants', { default: () => [], server: false })
|
|
const { data: partners, refresh: rP } = useLazyFetch<Partner[]>('/api/partners', { default: () => [], server: false })
|
|
|
|
const NAV: { id: string; label: string; icon: IconName; to: string }[] = [
|
|
{ id: 'n-overview', label: 'Overview', icon: 'home', to: '/' },
|
|
{ id: 'n-tenants', label: 'Tenants', icon: 'building', to: '/tenants' },
|
|
{ id: 'n-partners', label: 'Partners', icon: 'briefcase', to: '/partners' },
|
|
{ id: 'n-users', label: 'Users (global)', icon: 'users', to: '/users' },
|
|
{ id: 'n-support', label: 'Support', icon: 'help', to: '/support' },
|
|
{ id: 'n-billing', label: 'Platform billing', icon: 'card', to: '/billing' },
|
|
{ id: 'n-reports', label: 'Reports', icon: 'database', to: '/reports' },
|
|
{ id: 'n-infra', label: 'Infrastructure', icon: 'plug', to: '/infrastructure' },
|
|
{ id: 'n-flags', label: 'Feature flags', icon: 'shield', to: '/flags' },
|
|
{ id: 'n-audit', label: 'Audit log', icon: 'file', to: '/audit' },
|
|
{ id: 'n-team', label: 'Operator team', icon: 'users', to: '/operator-team' },
|
|
{ id: 'n-settings', label: 'Platform settings',icon: 'shield', to: '/settings' },
|
|
]
|
|
|
|
function tenantRows(): Row[] {
|
|
return (tenants.value ?? []).map((t) => ({
|
|
id: `t-${t._id}`,
|
|
groupLabel: 'Tenants',
|
|
title: t.name,
|
|
subtitle: `${t.domains?.[0] ?? t.slug} · ${t.plan} · ${t.status}`,
|
|
icon: 'building',
|
|
badge: t.status,
|
|
to: `/tenants/${t.slug}`,
|
|
}))
|
|
}
|
|
|
|
function partnerRows(): Row[] {
|
|
return (partners.value ?? []).map((p) => ({
|
|
id: `p-${p._id}`,
|
|
groupLabel: 'Partners',
|
|
title: p.name,
|
|
subtitle: `${p.domain} · ${p.customers} customers · ${p.marginPct}% margin`,
|
|
icon: 'briefcase',
|
|
badge: p.status,
|
|
to: `/partners/${p.slug}`,
|
|
}))
|
|
}
|
|
|
|
function flagRows(): Row[] {
|
|
return FLAGS.slice(0, 6).map((f) => ({
|
|
id: `f-${f.key}`,
|
|
groupLabel: 'Feature flags',
|
|
title: f.key,
|
|
subtitle: f.state === 'rollout' ? `rollout · ${f.pct}% · ${f.scope}` : `${f.state} · ${f.scope}`,
|
|
icon: 'plug',
|
|
to: '/flags',
|
|
}))
|
|
}
|
|
|
|
function navRows(): Row[] {
|
|
return NAV.map((n) => ({
|
|
id: n.id, groupLabel: 'Navigation', title: n.label, subtitle: 'navigate', icon: n.icon, to: n.to,
|
|
}))
|
|
}
|
|
|
|
function actionRows(): Row[] {
|
|
const firstTenant = tenants.value?.[0]
|
|
return [
|
|
{
|
|
id: 'a-impersonate',
|
|
groupLabel: 'Actions',
|
|
title: 'Impersonate user…',
|
|
subtitle: firstTenant ? `enter ${firstTenant.name} as a user` : 'no tenants yet',
|
|
icon: 'key',
|
|
run: () => firstTenant && openImpersonation(firstTenant),
|
|
},
|
|
{
|
|
id: 'a-incident',
|
|
groupLabel: 'Actions',
|
|
title: 'Open active incident',
|
|
subtitle: INCIDENT.title,
|
|
icon: 'bell',
|
|
run: () => openIncident(),
|
|
},
|
|
{
|
|
id: 'a-toggle-theme',
|
|
groupLabel: 'Actions',
|
|
title: 'Toggle theme',
|
|
subtitle: 'dark / light',
|
|
icon: 'shield',
|
|
run: () => {
|
|
const { state, setTheme } = useTweaks()
|
|
setTheme(state.value.theme === 'dark' ? 'light' : 'dark')
|
|
},
|
|
},
|
|
]
|
|
}
|
|
|
|
interface Group { label: string; items: Row[] }
|
|
|
|
const groups = computed<Group[]>(() => {
|
|
const q = query.value.trim().toLowerCase()
|
|
const all: Row[] = [
|
|
...tenantRows(),
|
|
...partnerRows(),
|
|
...flagRows(),
|
|
...navRows(),
|
|
...actionRows(),
|
|
]
|
|
const matches = q
|
|
? all.filter((r) => r.title.toLowerCase().includes(q) || (r.subtitle ?? '').toLowerCase().includes(q))
|
|
: all
|
|
const grouped = new Map<string, Row[]>()
|
|
for (const r of matches) {
|
|
const arr = grouped.get(r.groupLabel) ?? []
|
|
arr.push(r)
|
|
grouped.set(r.groupLabel, arr)
|
|
}
|
|
// Stable group order
|
|
const order = ['Tenants', 'Partners', 'Feature flags', 'Navigation', 'Actions']
|
|
return order
|
|
.filter((g) => grouped.has(g))
|
|
.map((g) => ({ label: g, items: (grouped.get(g) ?? []).slice(0, 8) }))
|
|
})
|
|
|
|
const flat = computed(() => groups.value.flatMap((g) => g.items))
|
|
|
|
watch(isOpen, async (v) => {
|
|
if (!v) return
|
|
query.value = ''
|
|
cursor.value = 0
|
|
await Promise.all([rT(), rP()])
|
|
await nextTick()
|
|
inputRef.value?.focus()
|
|
})
|
|
|
|
watch(query, () => {
|
|
cursor.value = 0
|
|
})
|
|
|
|
function activate(row?: Row) {
|
|
if (!row) return
|
|
if (row.run) row.run()
|
|
if (row.to) navigateTo(row.to)
|
|
close()
|
|
}
|
|
|
|
function onKey(e: KeyboardEvent) {
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault()
|
|
close()
|
|
}
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault()
|
|
cursor.value = Math.min(cursor.value + 1, Math.max(0, flat.value.length - 1))
|
|
}
|
|
if (e.key === 'ArrowUp') {
|
|
e.preventDefault()
|
|
cursor.value = Math.max(0, cursor.value - 1)
|
|
}
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault()
|
|
activate(flat.value[cursor.value])
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<Teleport to="body">
|
|
<div v-if="isOpen" class="palette-backdrop" @click="close">
|
|
<div class="palette" role="dialog" aria-label="Command palette" @click.stop>
|
|
<div class="input-row">
|
|
<UiIcon name="search" :size="14" />
|
|
<input
|
|
ref="inputRef"
|
|
v-model="query"
|
|
type="text"
|
|
placeholder="Search tenants, partners, actions…"
|
|
autocomplete="off"
|
|
@keydown="onKey"
|
|
/>
|
|
<span class="kbd">ESC</span>
|
|
</div>
|
|
|
|
<div class="results">
|
|
<template v-if="groups.length">
|
|
<div v-for="g in groups" :key="g.label" class="group">
|
|
<div class="group-label">{{ g.label }}</div>
|
|
<button
|
|
v-for="item in g.items"
|
|
:key="item.id"
|
|
type="button"
|
|
:class="['row', { active: item.id === flat[cursor]?.id }]"
|
|
@mouseenter="cursor = flat.findIndex((x) => x.id === item.id)"
|
|
@click="activate(item)"
|
|
>
|
|
<span class="icon-tile">
|
|
<UiIcon v-if="item.icon" :name="item.icon" :size="13" />
|
|
</span>
|
|
<span class="row-body">
|
|
<span class="row-title">{{ item.title }}</span>
|
|
<span v-if="item.subtitle" class="row-sub">{{ item.subtitle }}</span>
|
|
</span>
|
|
<Mono v-if="item.badge" dim>{{ item.badge }}</Mono>
|
|
</button>
|
|
</div>
|
|
</template>
|
|
<div v-else class="empty">
|
|
<Mono dim>// no matches for "{{ query }}"</Mono>
|
|
</div>
|
|
</div>
|
|
|
|
<footer>
|
|
<Mono dim>{{ flat.length }} {{ flat.length === 1 ? 'result' : 'results' }}</Mono>
|
|
<Mono dim>↑↓ navigate · ↵ open · esc close</Mono>
|
|
</footer>
|
|
</div>
|
|
</div>
|
|
</Teleport>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.palette-backdrop {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0, 0, 0, 0.55);
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: center;
|
|
padding: 15vh 24px 24px 24px;
|
|
z-index: 200;
|
|
}
|
|
|
|
.palette {
|
|
width: 100%;
|
|
max-width: 640px;
|
|
background: var(--elevated);
|
|
color: var(--text);
|
|
border: 1px solid var(--border);
|
|
border-radius: 10px;
|
|
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.5);
|
|
display: flex;
|
|
flex-direction: column;
|
|
max-height: 70vh;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.input-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 14px 18px;
|
|
border-bottom: 1px solid var(--border);
|
|
color: var(--text-mute);
|
|
}
|
|
.input-row input {
|
|
flex: 1;
|
|
border: 0;
|
|
outline: 0;
|
|
background: transparent;
|
|
font-family: inherit;
|
|
font-size: 15px;
|
|
color: var(--text);
|
|
}
|
|
.kbd {
|
|
font-family: var(--font-mono);
|
|
font-size: 10px;
|
|
padding: 2px 6px;
|
|
background: var(--surface);
|
|
border-radius: 3px;
|
|
color: var(--text-mute);
|
|
letter-spacing: 0.04em;
|
|
}
|
|
|
|
.results {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 6px;
|
|
}
|
|
.group { margin-bottom: 4px; }
|
|
.group-label {
|
|
padding: 10px 14px 4px 14px;
|
|
font-family: var(--font-mono);
|
|
font-size: 10px;
|
|
letter-spacing: 0.14em;
|
|
text-transform: uppercase;
|
|
font-weight: 500;
|
|
color: var(--text-mute);
|
|
}
|
|
|
|
.row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
width: 100%;
|
|
padding: 8px 14px;
|
|
background: transparent;
|
|
border: 0;
|
|
border-left: 2px solid transparent;
|
|
border-radius: 6px;
|
|
color: var(--text);
|
|
font-family: inherit;
|
|
cursor: pointer;
|
|
text-align: left;
|
|
}
|
|
.row.active {
|
|
background: rgba(212, 255, 58, 0.08);
|
|
border-left-color: var(--accent);
|
|
}
|
|
.icon-tile {
|
|
width: 22px;
|
|
height: 22px;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: var(--text-mute);
|
|
flex-shrink: 0;
|
|
}
|
|
.row.active .icon-tile { color: var(--accent); }
|
|
|
|
.row-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
|
.row-title { font-size: 13px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.row-sub {
|
|
font-size: 11px;
|
|
color: var(--text-mute);
|
|
font-family: var(--font-mono);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.empty { padding: 40px 18px; text-align: center; }
|
|
|
|
footer {
|
|
padding: 8px 14px;
|
|
border-top: 1px solid var(--border);
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
</style>
|