Per RFC-WS-PRIMEVUE-PLAN-2-5 §5.1–§5.5 plus the AD-2.5-W1 option-A supersession (no sub on dropdown items either, accepted divergence). Atomic changes: - AppTopbar: brand block (gradient "C" mark + Crewli wordmark) removed per Fix 1; the #start slot now renders <AppBreadcrumb /> per Fix 2. Legacy meta-based useBreadcrumb consumption (breadcrumbModel computed, vue-router useRouter import, command-based PrimeVue Breadcrumb model) is gone; AppBreadcrumb owns the registry-driven path. Dead topbar-mark-shadow scoped CSS rule deleted. - AppBreadcrumb: import updated to the renamed useBreadcrumb. - AppSidebar: docstring updated to make the Fix 3 vertical order (Header → Nav → Switcher, switcher bottom-anchored) explicit. No template change needed: SidebarNav's root <nav class="flex-1"> already fills available column space, naturally pushing WorkspaceSwitcher to the bottom (two flex-1 siblings would split the column 50/50 and compress the nav — a separate spacer element is structurally wrong). - WorkspaceSwitcher: dropdown panel restructured per crewli-starter reference. Semantic class markers (.popover-head/.title/.link/.list/ .opt/.is-current/.ws-logo/.name/.check-mark/.foot) added alongside Tailwind utilities so specs assert structure with stable selectors. Footer buttons wired to placeholder createWorkspace / inviteUser handlers (console.warn + TODO) until the flows ship. Manage link stays a non-navigating label (no v2-workspaces-manage route yet). No sub line on any dropdown row (AD-2.5-W1 option A). Atomic legacy useBreadcrumb retirement (planned since P1): - Legacy route-meta-driven useBreadcrumb + toBreadcrumbItems + BreadcrumbRouteRecord types deleted entirely (only AppTopbar consumed it, and that consumption is gone after Fix 2). - useNavBreadcrumb → useBreadcrumb (single SoT for breadcrumb chain). - NavBreadcrumbItem → BreadcrumbItem. - AppBreadcrumb.vue import updated to the new name. - SidebarNav.vue docstring reference scrubbed to the new name. - useBreadcrumb.spec.ts: 10 legacy toBreadcrumbItems specs removed; 4 walkNavTree specs retained. AppTopbar.spec.ts: - vue-router mock simplified (route.matched no longer relevant). - AppBreadcrumb stubbed in #start; legacy command-vs-route assertion removed; new spec verifies AppBreadcrumb is rendered. WorkspaceSwitcher.spec.ts: 5 new dropdown specs (header / row count / current-row checkmark / footer buttons / no-sub on rows). Suite delta: 557 → 552 (−5 net: −10 legacy toBreadcrumbItems specs, +5 Fix 5 dropdown specs, −1 obsolete AppTopbar breadcrumb-model spec, +1 new AppTopbar AppBreadcrumb-presence spec). vue-tsc clean. Scoped ESLint clean (0 errors). All 3 re-grep checks returned 0 hits (useNavBreadcrumb/NavBreadcrumbItem, topbar brand selectors, standalone "sub" identifier in WorkspaceSwitcher — only documentation comments referencing the no-sub state remain, which describe absence by design). Manual smoke skipped (Auto Mode); coverage from the post-edit specs includes AppBreadcrumb-in-#start, dropdown structure, and trigger no-sub. Recommend Bert run `pnpm --filter crewli-app dev` and verify the 6 checks listed in the prompt before merging. Known divergence from crewli-starter (accepted): - Dropdown rows are ~16px shorter than crewli-starter (no sub line). Tracked as WORKSPACE-DROPDOWN-SUB-CONTENT for a future RFC with the required backend scope (organisations.type enum + metrics). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
424 lines
16 KiB
Vue
424 lines
16 KiB
Vue
<script setup lang="ts">
|
|
/**
|
|
* AppTopbar — production port of crewli-starter AppTopbar.vue.
|
|
*
|
|
* Wiring:
|
|
* - Hamburger → shell.setMobileOpen(true) (mobile only, lg:hidden)
|
|
* - Breadcrumb: <AppBreadcrumb /> primitive (registry-driven via APP_NAVIGATION).
|
|
* Brand block was removed in Plan 2.5 P5 (RFC §5.1 Fix 1) — sidebar
|
|
* owns Crewli identity.
|
|
* - Mobile workspace button: org gradient via computeOrgGradient() + initials
|
|
* - Search: static InputText with ⌘K hint (no backend, chrome only)
|
|
* - Density toggle: shell.setDensity() with flipped value
|
|
* - Theme toggle: shell.setTheme() with flipped value
|
|
* - Notifications: stubbed with count=0 — useNotificationStore is a toast/snackbar
|
|
* queue (visible/message/type) not a notification feed; real feed is not
|
|
* foundation scope.
|
|
* TODO TECH-WS-GUI-REDESIGN: real notification feed — not foundation scope
|
|
* - User menu: PrimeVue Menu with #start slot for user header; Sign out → authStore.logout()
|
|
* - All click-outside: PrimeVue Popover/Menu built-in dismissal (no document.addEventListener)
|
|
*
|
|
* Notifications decision (A7): useNotificationStore exposes only
|
|
* { visible, message, type, timeout, show, hide } — a toast/snackbar queue,
|
|
* not a persistent notification feed with unread counts or list items.
|
|
* Decision: STUB at count=0, empty list, with TODO comment above.
|
|
*
|
|
* Icon convention: import Icon from '@/components/Icon.vue', <Icon name="tabler-x" :size="N" />
|
|
*
|
|
* RFC AD-3: PrimeVue Menubar is the chrome root (wrap-don't-rewrite).
|
|
* Breadcrumb block → #start slot; search/notifications/user cluster → #end slot.
|
|
* :model="[]" — no Menubar menu items; Menubar is the AD-3 chrome shell only.
|
|
*
|
|
* Styling: crewli-starter CSS translated to Tailwind inline.
|
|
* <style scoped> used only for:
|
|
* 1. ws-mobile-btn-shadow — inset box-shadow (no Tailwind utility at this
|
|
* granularity, RFC §7.4)
|
|
*/
|
|
|
|
import Avatar from 'primevue/avatar'
|
|
import InputText from 'primevue/inputtext'
|
|
import Menubar from 'primevue/menubar'
|
|
import Menu from 'primevue/menu'
|
|
import OverlayBadge from 'primevue/overlaybadge'
|
|
import Popover from 'primevue/popover'
|
|
import { computed, ref } from 'vue'
|
|
import type { MenuItem } from 'primevue/menuitem'
|
|
import Icon from '@/components/Icon.vue'
|
|
import { useAuthStore } from '@/stores/useAuthStore'
|
|
import { useShellUiStore } from '@/stores/useShellUiStore'
|
|
import { computeOrgGradient } from '@/utils/v2/gradient'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Stores
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const shell = useShellUiStore()
|
|
const authStore = useAuthStore()
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// User initials derived from full_name
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const userInitials = computed<string>(() => {
|
|
const name = authStore.user?.full_name?.trim() ?? ''
|
|
|
|
if (!name)
|
|
return '?'
|
|
|
|
const words = name.split(/\s+/)
|
|
|
|
return words.length >= 2
|
|
? (words[0][0] + words[words.length - 1][0]).toUpperCase()
|
|
: name.slice(0, 2).toUpperCase()
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mobile workspace button
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const mobileOrgGradient = computed<[string, string]>(() => {
|
|
const id = authStore.currentOrganisation?.id ?? ''
|
|
|
|
return computeOrgGradient(id)
|
|
})
|
|
|
|
const mobileOrgInitials = computed<string>(() => {
|
|
const name = authStore.currentOrganisation?.name?.trim() ?? ''
|
|
|
|
if (!name)
|
|
return '?'
|
|
|
|
const words = name.split(/\s+/)
|
|
|
|
return words.length >= 2
|
|
? (words[0][0] + words[1][0]).toUpperCase()
|
|
: name.slice(0, 2).toUpperCase()
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Density toggle
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function toggleDensity(): void {
|
|
shell.setDensity(shell.density === 'comfortable' ? 'compact' : 'comfortable')
|
|
}
|
|
|
|
const densityAriaLabel = computed<string>(() =>
|
|
shell.density === 'comfortable' ? 'Switch to compact' : 'Switch to comfortable',
|
|
)
|
|
|
|
const densityIcon = computed<string>(() =>
|
|
shell.density === 'comfortable'
|
|
? 'tabler-layout-distribute-vertical'
|
|
: 'tabler-layout-distribute-horizontal',
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Theme toggle
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function toggleTheme(): void {
|
|
shell.setTheme(shell.theme === 'dark' ? 'light' : 'dark')
|
|
}
|
|
|
|
const themeAriaLabel = computed<string>(() =>
|
|
shell.theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode',
|
|
)
|
|
|
|
const themeIcon = computed<string>(() =>
|
|
shell.theme === 'dark' ? 'tabler-sun' : 'tabler-moon',
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Notifications — STUBBED (see file header for A7 decision)
|
|
// TODO TECH-WS-GUI-REDESIGN: real notification feed — not foundation scope
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const notifCount = 0
|
|
const notifPopoverRef = ref<InstanceType<typeof Popover> | null>(null)
|
|
|
|
function toggleNotifPopover(event: MouseEvent): void {
|
|
notifPopoverRef.value?.toggle(event)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// User menu — PrimeVue Menu (replaces crewli-starter manual mousedown listener)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const userMenuRef = ref<InstanceType<typeof Menu> | null>(null)
|
|
|
|
function toggleUserMenu(event: MouseEvent): void {
|
|
userMenuRef.value?.toggle(event)
|
|
}
|
|
|
|
const userMenuItems = computed<MenuItem[]>(() => [
|
|
{
|
|
items: [
|
|
{
|
|
label: 'Profile',
|
|
icon: 'tabler-user',
|
|
command: () => {
|
|
// TODO TECH-WS-GUI-REDESIGN: routes not yet defined
|
|
},
|
|
},
|
|
{
|
|
label: 'Account settings',
|
|
icon: 'tabler-settings',
|
|
command: () => {
|
|
// TODO TECH-WS-GUI-REDESIGN: routes not yet defined
|
|
},
|
|
},
|
|
{
|
|
label: 'Workspace settings',
|
|
icon: 'tabler-adjustments',
|
|
command: () => {
|
|
// TODO TECH-WS-GUI-REDESIGN: routes not yet defined
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
items: [
|
|
{
|
|
label: 'Keyboard shortcuts',
|
|
icon: 'tabler-keyboard',
|
|
command: () => {
|
|
// TODO TECH-WS-GUI-REDESIGN: routes not yet defined
|
|
},
|
|
},
|
|
{
|
|
label: 'Help & support',
|
|
icon: 'tabler-help',
|
|
command: () => {
|
|
// TODO TECH-WS-GUI-REDESIGN: routes not yet defined
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
items: [
|
|
{
|
|
label: 'Sign out',
|
|
icon: 'tabler-logout',
|
|
command: () => {
|
|
authStore.logout()
|
|
},
|
|
},
|
|
],
|
|
},
|
|
])
|
|
</script>
|
|
|
|
<template>
|
|
<!--
|
|
RFC AD-3: PrimeVue Menubar is the chrome root.
|
|
:model="[]" — no Menubar menu items; Menubar is the AD-3 chrome shell only.
|
|
:pt maps Menubar internal regions to the existing hand-rolled chrome classes
|
|
so visual layout is not gratuitously altered (pixel parity deferred to Bert).
|
|
-->
|
|
<Menubar
|
|
:model="[]"
|
|
:pt="{
|
|
root: 'sticky top-0 z-30 h-[var(--topbar-h,56px)] border-0 border-b border-[var(--p-content-border-color)] rounded-none bg-[var(--p-content-background)] px-5',
|
|
start: 'flex items-center gap-[6px] min-w-0',
|
|
end: 'ms-auto flex items-center gap-[6px]',
|
|
}"
|
|
>
|
|
<template #start>
|
|
<div data-tb="breadcrumb">
|
|
<!--
|
|
Hamburger — mobile only (lg:hidden).
|
|
.hamburger: display none at >=lg, display inline-flex at <768px
|
|
.icon-btn: 38x38, rounded, transparent bg, fg-muted color, hover bg-hover
|
|
-->
|
|
<button
|
|
type="button"
|
|
class="lg:hidden inline-flex h-[38px] w-[38px] items-center justify-center rounded-[var(--p-border-radius)] border-0 bg-transparent text-[var(--p-text-muted-color)] transition-colors duration-150 hover:bg-[var(--p-content-hover-background)] hover:text-[var(--p-text-color)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--p-primary-color)] focus-visible:outline-offset-1"
|
|
aria-label="Open menu"
|
|
@click="shell.setMobileOpen(true)"
|
|
>
|
|
<Icon
|
|
name="tabler-menu-2"
|
|
:size="20"
|
|
/>
|
|
</button>
|
|
|
|
<!--
|
|
Breadcrumb — registry-driven via <AppBreadcrumb /> (consumes
|
|
APP_NAVIGATION). The Crewli brand block was removed in Plan 2.5
|
|
P5 (RFC §5.1 Fix 1); the sidebar owns the Crewli identity now.
|
|
Hidden on mobile per crewli-starter (<768px: display:none!important).
|
|
-->
|
|
<nav
|
|
class="hidden lg:flex items-center"
|
|
aria-label="Breadcrumb"
|
|
>
|
|
<AppBreadcrumb />
|
|
</nav>
|
|
</div>
|
|
</template>
|
|
|
|
<template #end>
|
|
<!--
|
|
Mobile workspace button — visible only <lg.
|
|
.ws-mobile-btn: display none (>= 1024px). On mobile: display flex.
|
|
38x38, rounded, color #fff, font-bold 12px, gradient bg via inline :style
|
|
(dynamic hex pair — RFC §7.4). Box-shadow via scoped CSS.
|
|
Plan-3 fix: free sibling in #end flex row — NOT nested inside data-tb="search".
|
|
-->
|
|
<button
|
|
v-if="authStore.currentOrganisation"
|
|
type="button"
|
|
class="ws-mobile-btn-shadow lg:hidden inline-flex h-[38px] w-[38px] flex-shrink-0 items-center justify-center rounded-[var(--p-border-radius)] border-0 text-[12px] font-bold text-white"
|
|
:style="{
|
|
background: `linear-gradient(135deg, ${mobileOrgGradient[0]}, ${mobileOrgGradient[1]})`,
|
|
}"
|
|
:aria-label="`Workspace: ${authStore.currentOrganisation.name}`"
|
|
>
|
|
{{ mobileOrgInitials }}
|
|
</button>
|
|
|
|
<!--
|
|
Search — static chrome, no backend.
|
|
data-tb="search" carries the original layout classes of the search block
|
|
so flex participation is identical to Plan-2 (direct #end flex child with
|
|
relative + hidden sm:block + width). No inner wrapper div needed.
|
|
.search: position relative, w-[240px] sm / w-[320px] lg, max-w-full
|
|
.search input: h-[38px], px-[12px] ps-[36px], bg-surface-alt, border rounded
|
|
.search .kbd: absolute right-2, text-[11px], px-[6px] py-[2px], border rounded
|
|
Hidden on smallest viewports per crewli-starter (<768px: width 0 / display:none)
|
|
-->
|
|
<div
|
|
data-tb="search"
|
|
class="relative hidden sm:block w-[240px] lg:w-[320px] max-w-full"
|
|
>
|
|
<Icon
|
|
name="tabler-search"
|
|
:size="16"
|
|
class="pointer-events-none absolute left-[11px] top-1/2 -translate-y-1/2 text-[var(--p-text-muted-color)]"
|
|
/>
|
|
<InputText
|
|
class="h-[38px] w-full rounded-[var(--p-border-radius)] border border-transparent bg-[var(--p-content-hover-background)] ps-[36px] pe-[52px] text-[var(--p-text-color)] placeholder:text-[var(--p-text-muted-color)] focus:border-[var(--p-primary-color)] focus:bg-[var(--p-content-background)] focus:shadow-[0_0_0_3px_var(--p-primary-50)] focus:outline-none transition-[border-color,background,box-shadow] duration-150"
|
|
placeholder="Search artists, crew, events..."
|
|
:pt="{ root: { 'data-search-input': '' } }"
|
|
/>
|
|
<span class="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 rounded-[var(--p-border-radius-sm,4px)] border border-[var(--p-content-border-color)] bg-[var(--p-content-background)] px-[6px] py-[2px] font-mono text-[11px] text-[var(--p-text-muted-color)]">
|
|
⌘K
|
|
</span>
|
|
</div>
|
|
|
|
<!--
|
|
Density toggle.
|
|
.icon-btn: 38x38, inline-flex, centered, rounded, transparent, fg-muted, hover bg-hover
|
|
-->
|
|
<button
|
|
type="button"
|
|
class="inline-flex h-[38px] w-[38px] items-center justify-center rounded-[var(--p-border-radius)] border-0 bg-transparent text-[var(--p-text-muted-color)] transition-colors duration-150 hover:bg-[var(--p-content-hover-background)] hover:text-[var(--p-text-color)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--p-primary-color)] focus-visible:outline-offset-1"
|
|
:aria-label="densityAriaLabel"
|
|
@click="toggleDensity"
|
|
>
|
|
<Icon
|
|
:name="densityIcon"
|
|
:size="18"
|
|
/>
|
|
</button>
|
|
|
|
<!-- Theme toggle. -->
|
|
<button
|
|
type="button"
|
|
class="inline-flex h-[38px] w-[38px] items-center justify-center rounded-[var(--p-border-radius)] border-0 bg-transparent text-[var(--p-text-muted-color)] transition-colors duration-150 hover:bg-[var(--p-content-hover-background)] hover:text-[var(--p-text-color)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--p-primary-color)] focus-visible:outline-offset-1"
|
|
:aria-label="themeAriaLabel"
|
|
@click="toggleTheme"
|
|
>
|
|
<Icon
|
|
:name="themeIcon"
|
|
:size="18"
|
|
/>
|
|
</button>
|
|
|
|
<!--
|
|
Notifications — stubbed bell with OverlayBadge.
|
|
PrimeVue Popover replaces crewli-starter's manual document.addEventListener('mousedown', …)
|
|
TODO TECH-WS-GUI-REDESIGN: real notification feed — not foundation scope
|
|
-->
|
|
<div data-tb="notifications">
|
|
<OverlayBadge
|
|
:value="notifCount > 0 ? String(notifCount) : undefined"
|
|
severity="danger"
|
|
>
|
|
<button
|
|
type="button"
|
|
class="inline-flex h-[38px] w-[38px] items-center justify-center rounded-[var(--p-border-radius)] border-0 bg-transparent text-[var(--p-text-muted-color)] transition-colors duration-150 hover:bg-[var(--p-content-hover-background)] hover:text-[var(--p-text-color)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--p-primary-color)] focus-visible:outline-offset-1"
|
|
aria-label="Notifications"
|
|
@click="toggleNotifPopover"
|
|
>
|
|
<Icon
|
|
name="tabler-bell"
|
|
:size="18"
|
|
/>
|
|
</button>
|
|
</OverlayBadge>
|
|
|
|
<Popover ref="notifPopoverRef">
|
|
<div class="min-w-[320px] p-4 text-[13px] text-[var(--p-text-muted-color)]">
|
|
No notifications
|
|
</div>
|
|
</Popover>
|
|
</div>
|
|
|
|
<!--
|
|
User menu — PrimeVue Avatar + Menu.
|
|
Avatar: 32x32, rounded-full, initials, gradient bg.
|
|
Menu: PrimeVue Menu replaces crewli-starter's manual click-outside listener.
|
|
#start slot used for user info header.
|
|
-->
|
|
<div data-tb="user">
|
|
<Avatar
|
|
:label="userInitials"
|
|
shape="circle"
|
|
class="cursor-pointer"
|
|
:pt="{ root: { style: 'background: linear-gradient(135deg, #f472b6, var(--p-primary-500, #0d9488)); color: #fff;' } }"
|
|
aria-label="User menu"
|
|
@click="toggleUserMenu"
|
|
/>
|
|
|
|
<Menu
|
|
ref="userMenuRef"
|
|
:model="userMenuItems"
|
|
popup
|
|
>
|
|
<template #start>
|
|
<div class="flex items-center gap-3 px-4 py-3 border-b border-[var(--p-content-border-color)]">
|
|
<Avatar
|
|
:label="userInitials"
|
|
shape="circle"
|
|
:pt="{ root: { style: 'background: linear-gradient(135deg, #f472b6, var(--p-primary-500, #0d9488)); color: #fff; width:40px; height:40px; font-size:14px;' } }"
|
|
/>
|
|
<div class="min-w-0">
|
|
<div class="truncate text-[13.5px] font-semibold text-[var(--p-text-color)]">
|
|
{{ authStore.user?.full_name ?? '' }}
|
|
</div>
|
|
<div class="truncate text-[12px] text-[var(--p-text-muted-color)]">
|
|
{{ authStore.user?.email ?? '' }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</Menu>
|
|
</div>
|
|
</template>
|
|
</Menubar>
|
|
</template>
|
|
|
|
<style scoped>
|
|
/**
|
|
* ws-mobile-btn-shadow — inset directional box-shadow on the mobile workspace
|
|
* button. Tailwind has no inset-shadow utility at this depth/direction
|
|
* granularity → RFC §7.4 last-resort. Background gradient is set via inline
|
|
* :style (dynamic hex pair from computeOrgGradient).
|
|
*/
|
|
.ws-mobile-btn-shadow {
|
|
box-shadow: inset 0 -2px 0 rgb(0 0 0 / 10%);
|
|
}
|
|
</style>
|