0bd4e5498e
- portal: new admin/ and partner/ surfaces with full component library (AppLauncher, Avatar, Badge, Card, Modal, Tabs, etc.), composables, layouts, partner-routing middleware, and supporting server APIs - pricing: Price schema/module with operator CRUD, pricing.vue catalog UI, Subscription extended with cycle/currency/perSeatAmount/seats snapshots for stable MRR aggregation - partner staff: User.partnerId, invite-partner-user DTO and flow, /partners/:slug/users endpoints, InvitePartnerUserModal, shared dezky-partner-staff Authentik group - /me: partner-aware endpoint returning user + partner context so portal can route between end-user and partner-admin surfaces - tenant: seats field for portfolio displays and future MRR calculations - operator: pricing page, signed-out page, useMe/useToast composables, ToastStack
203 lines
5.7 KiB
Vue
203 lines
5.7 KiB
Vue
<script setup lang="ts">
|
|
import type { IconName } from './UiIcon.vue'
|
|
|
|
interface NavItem {
|
|
id: string
|
|
label: string
|
|
icon: IconName
|
|
badge?: number | string
|
|
href: string
|
|
}
|
|
interface NavSection { sec: string }
|
|
type NavRow = NavItem | NavSection
|
|
|
|
defineProps<{ current: string }>()
|
|
const emit = defineEmits<{ navigate: [string] }>()
|
|
|
|
const { collapsed, toggle } = useSidebar()
|
|
|
|
// Layout mirrors operator-app.jsx OP_NAV.
|
|
const NAV: NavRow[] = [
|
|
{ id: 'overview', label: 'Overview', icon: 'home', href: '/' },
|
|
{ id: 'tenants', label: 'Tenants', icon: 'building', href: '/tenants' },
|
|
{ id: 'partners', label: 'Partners', icon: 'briefcase', href: '/partners' },
|
|
{ id: 'users', label: 'Users (global)', icon: 'users', href: '/users' },
|
|
{ id: 'support', label: 'Support', icon: 'help', href: '/support' },
|
|
{ sec: 'Commercial' },
|
|
{ id: 'pricing', label: 'Pricing', icon: 'card', href: '/pricing' },
|
|
{ id: 'billing', label: 'Platform billing', icon: 'card', href: '/billing' },
|
|
{ id: 'reports', label: 'Reports', icon: 'database', href: '/reports' },
|
|
{ sec: 'Operations' },
|
|
{ id: 'infra', label: 'Infrastructure', icon: 'plug', href: '/infrastructure' },
|
|
{ id: 'flags', label: 'Feature flags', icon: 'shield', href: '/flags' },
|
|
{ id: 'audit', label: 'Audit log', icon: 'file', href: '/audit' },
|
|
{ sec: 'Platform' },
|
|
{ id: 'team', label: 'Operator team', icon: 'users', href: '/operator-team' },
|
|
{ id: 'settings', label: 'Platform settings',icon: 'shield', href: '/settings' },
|
|
]
|
|
|
|
const isSection = (r: NavRow): r is NavSection => 'sec' in r
|
|
</script>
|
|
|
|
<template>
|
|
<aside :class="['sidebar', { collapsed }]">
|
|
<div class="brand">
|
|
<span class="tile"><NodeMark :size="22" fg="#F4F3EE" accent="#D4FF3A" /></span>
|
|
<div v-if="!collapsed" class="brand-meta">
|
|
<div class="brand-name">dezky · ops</div>
|
|
<div class="brand-host">operator.dezky.local</div>
|
|
</div>
|
|
</div>
|
|
|
|
<nav>
|
|
<template v-for="(item, i) in NAV" :key="i">
|
|
<div v-if="isSection(item) && !collapsed" class="section">{{ item.sec }}</div>
|
|
<div v-else-if="isSection(item)" class="divider" />
|
|
<NuxtLink
|
|
v-else
|
|
:to="item.href"
|
|
:class="['row', { active: current === item.id }]"
|
|
:title="collapsed ? item.label : undefined"
|
|
@click="emit('navigate', item.id)"
|
|
>
|
|
<UiIcon :name="item.icon" :size="14" />
|
|
<span v-if="!collapsed" class="label">{{ item.label }}</span>
|
|
<span
|
|
v-if="!collapsed && item.badge !== undefined && item.badge !== 0"
|
|
class="badge"
|
|
:class="{ alert: item.badge === '!' }"
|
|
>{{ item.badge }}</span>
|
|
<span v-else-if="collapsed && item.badge === '!'" class="badge-dot" />
|
|
</NuxtLink>
|
|
</template>
|
|
</nav>
|
|
|
|
<div class="foot">
|
|
<button class="toggle" @click="toggle">
|
|
<UiIcon :name="collapsed ? 'chevRight' : 'chevLeft'" :size="12" />
|
|
<span v-if="!collapsed">collapse · ⌘[</span>
|
|
</button>
|
|
</div>
|
|
</aside>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.sidebar {
|
|
width: 232px;
|
|
flex-shrink: 0;
|
|
background: var(--side-bg);
|
|
color: var(--side-text);
|
|
border-right: 1px solid var(--side-border);
|
|
display: flex;
|
|
flex-direction: column;
|
|
transition: width 180ms ease;
|
|
}
|
|
|
|
.sidebar.collapsed { width: 56px; }
|
|
|
|
.brand {
|
|
padding: 14px 12px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
min-height: 56px;
|
|
border-bottom: 1px solid var(--side-border);
|
|
}
|
|
|
|
.tile {
|
|
width: 32px;
|
|
height: 32px;
|
|
border-radius: 7px;
|
|
background: #F4F3EE;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.brand-meta { flex: 1; min-width: 0; }
|
|
.brand-name { font-family: var(--font-mono); font-size: 12px; font-weight: 600; }
|
|
.brand-host { font-family: var(--font-mono); font-size: 10px; color: var(--side-mute); margin-top: 2px; }
|
|
|
|
nav { flex: 1; padding: 8px 6px; overflow-y: auto; }
|
|
|
|
.section {
|
|
padding: 12px 10px 4px 10px;
|
|
font-family: var(--font-mono);
|
|
font-size: 10px;
|
|
letter-spacing: 0.16em;
|
|
text-transform: uppercase;
|
|
color: var(--side-mute);
|
|
font-weight: 500;
|
|
}
|
|
|
|
.divider { height: 1px; background: var(--side-border); margin: 10px 8px; }
|
|
|
|
.row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 6px 10px;
|
|
background: transparent;
|
|
color: var(--side-dim);
|
|
border: none;
|
|
border-radius: 5px;
|
|
text-decoration: none;
|
|
font-family: inherit;
|
|
font-size: 12.5px;
|
|
margin-bottom: 1px;
|
|
transition: background 120ms;
|
|
position: relative;
|
|
}
|
|
|
|
.sidebar.collapsed .row { padding: 8px 0; justify-content: center; }
|
|
|
|
.row:hover { background: var(--side-hover); color: var(--side-text); }
|
|
.row.active { background: var(--side-active); color: var(--side-text); font-weight: 500; }
|
|
|
|
.label { flex: 1; }
|
|
|
|
.badge {
|
|
font-family: var(--font-mono);
|
|
font-size: 10px;
|
|
padding: 0 6px;
|
|
min-width: 18px;
|
|
height: 16px;
|
|
border-radius: 3px;
|
|
background: var(--side-hover);
|
|
color: var(--side-dim);
|
|
font-weight: 600;
|
|
line-height: 16px;
|
|
text-align: center;
|
|
}
|
|
.badge.alert { background: var(--bad); color: #fff; }
|
|
|
|
.badge-dot {
|
|
position: absolute;
|
|
top: 4px;
|
|
right: 6px;
|
|
width: 6px;
|
|
height: 6px;
|
|
border-radius: 999px;
|
|
background: var(--bad);
|
|
}
|
|
|
|
.foot { border-top: 1px solid var(--side-border); }
|
|
|
|
.toggle {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
width: 100%;
|
|
padding: 10px 16px;
|
|
background: transparent;
|
|
border: none;
|
|
color: var(--side-mute);
|
|
font-family: var(--font-mono);
|
|
font-size: 10px;
|
|
letter-spacing: 0.06em;
|
|
cursor: pointer;
|
|
}
|
|
.sidebar.collapsed .toggle { justify-content: center; padding: 10px 0; }
|
|
</style>
|