Files
dezky/apps/operator/components/CommandPalette.vue
T
Ronni Baslund c71e782dc0 feat(operator): command palette, impersonation, incident, tweaks (O.8)
- 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.
2026-05-24 08:34:34 +02:00

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>