feat(gui-v2): port WorkspaceSwitcher to TypeScript
Ports crewli-starter WorkspaceSwitcher into the Crewli SPA as production TypeScript: PrimeVue Popover replaces the manual click-outside listener, data is derived from useAuthStore/useOrganisationStore (no new store), gradient pairs are deterministic via a new pure util with full Vitest coverage. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
256
apps/app/src/components-v2/layout/WorkspaceSwitcher.vue
Normal file
256
apps/app/src/components-v2/layout/WorkspaceSwitcher.vue
Normal file
@@ -0,0 +1,256 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* WorkspaceSwitcher — ported from crewli-starter WorkspaceSwitcher.vue.
|
||||
*
|
||||
* Data: read-only from useAuthStore (organisations, currentOrganisation,
|
||||
* setActiveOrganisation). This component owns NO org state — RFC AD-G4.
|
||||
*
|
||||
* Popover: PrimeVue <Popover> replaces the manual document.mousedown
|
||||
* click-outside listener from crewli-starter. Toggle via popoverRef.toggle($event).
|
||||
*
|
||||
* Icons: Crewli Icon.vue convention — name="tabler-x" :size="N".
|
||||
*
|
||||
* Styling: crewli-starter CSS selectors translated to Tailwind utilities inline.
|
||||
* One <style scoped> block covers the two exceptions documented below.
|
||||
*/
|
||||
|
||||
import Popover from 'primevue/popover'
|
||||
import { computed, ref } from 'vue'
|
||||
import Icon from '@/components/Icon.vue'
|
||||
import { useAuthStore } from '@/stores/useAuthStore'
|
||||
import { computeOrgGradient } from '@/utils/v2/gradient'
|
||||
import type { Organisation } from '@/types/auth'
|
||||
|
||||
defineProps<{
|
||||
/**
|
||||
* When true (collapsed sidebar), hide the name/sub meta text and
|
||||
* show only the logo square — mirrors crewli-starter's collapsed prop.
|
||||
*/
|
||||
collapsed?: boolean
|
||||
}>()
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived current-workspace display object
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface WorkspaceDisplay {
|
||||
id: string
|
||||
initials: string
|
||||
name: string
|
||||
/** The role string is the relevant context identifier in Crewli. */
|
||||
sub: string
|
||||
gradient: [string, string]
|
||||
}
|
||||
|
||||
function buildDisplay(org: Organisation): WorkspaceDisplay {
|
||||
const words = org.name.trim().split(/\s+/)
|
||||
|
||||
const initials
|
||||
= words.length >= 2
|
||||
? (words[0][0] + words[1][0]).toUpperCase()
|
||||
: org.name.slice(0, 2).toUpperCase()
|
||||
|
||||
return {
|
||||
id: org.id,
|
||||
initials,
|
||||
name: org.name,
|
||||
sub: org.role,
|
||||
gradient: computeOrgGradient(org.id),
|
||||
}
|
||||
}
|
||||
|
||||
const current = computed<WorkspaceDisplay | null>(() => {
|
||||
const org = authStore.currentOrganisation
|
||||
|
||||
return org ? buildDisplay(org) : null
|
||||
})
|
||||
|
||||
// Sorted list: active org first, then the rest alphabetically
|
||||
const allOrgs = computed<WorkspaceDisplay[]>(() => {
|
||||
const currentId = authStore.currentOrganisation?.id
|
||||
|
||||
return [...authStore.organisations]
|
||||
.sort((a, b) => {
|
||||
if (a.id === currentId)
|
||||
return -1
|
||||
if (b.id === currentId)
|
||||
return 1
|
||||
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
.map(buildDisplay)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Popover plumbing — PrimeVue Popover replaces the manual mousedown listener
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const popoverRef = ref<InstanceType<typeof Popover> | null>(null)
|
||||
|
||||
function toggle(event: MouseEvent): void {
|
||||
popoverRef.value?.toggle(event)
|
||||
}
|
||||
|
||||
function selectOrg(ws: WorkspaceDisplay): void {
|
||||
if (ws.id === authStore.currentOrganisation?.id) {
|
||||
popoverRef.value?.hide()
|
||||
|
||||
return
|
||||
}
|
||||
authStore.setActiveOrganisation(ws.id)
|
||||
popoverRef.value?.hide()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative flex-shrink-0 border-t border-[var(--p-content-border-color)] p-[10px]">
|
||||
<!-- Trigger button -->
|
||||
<!-- .ws-switcher .trigger: flex, items-center, gap, w-full, px/py, rounded, border, bg-transparent, color, transition -->
|
||||
<button
|
||||
class="flex w-full items-center gap-[10px] rounded-[var(--p-border-radius)] border border-transparent bg-transparent px-[10px] py-[8px] text-[var(--p-text-color)] transition-colors duration-150 hover:bg-[var(--p-content-hover-background)]"
|
||||
:class="[
|
||||
collapsed ? 'justify-center' : '',
|
||||
]"
|
||||
aria-haspopup="true"
|
||||
@click="toggle"
|
||||
>
|
||||
<!-- Logo square (gradient background is bespoke: dynamic hex pair cannot be a static Tailwind class — RFC §7.4 justified inline-style) -->
|
||||
<span
|
||||
v-if="current"
|
||||
class="ws-logo-square flex-shrink-0 rounded-[var(--p-border-radius)] inline-flex items-center justify-center text-white font-bold text-[12px]"
|
||||
:style="{ background: `linear-gradient(135deg, ${current.gradient[0]}, ${current.gradient[1]})` }"
|
||||
>
|
||||
{{ current.initials }}
|
||||
</span>
|
||||
|
||||
<!-- Meta: name + sub (hidden in collapsed mode) -->
|
||||
<!-- .ws-switcher .meta: flex-1, min-w-0, flex-col, line-height, text-left -->
|
||||
<span
|
||||
v-if="!collapsed && current"
|
||||
class="flex flex-1 min-w-0 flex-col text-left leading-[1.2]"
|
||||
>
|
||||
<!-- .ws-switcher .meta .name: text-[13.5px], font-semibold, truncate -->
|
||||
<span class="truncate text-[13.5px] font-semibold text-[var(--p-text-color)]">
|
||||
{{ current.name }}
|
||||
</span>
|
||||
<!-- .ws-switcher .meta .sub: text-[11.5px], muted, truncate -->
|
||||
<span class="truncate text-[11.5px] text-[var(--p-text-muted-color)]">
|
||||
{{ current.sub }}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<!-- Chevron (.ws-switcher .chev: color fg-subtle, flex-shrink-0) -->
|
||||
<Icon
|
||||
v-if="!collapsed"
|
||||
name="tabler-chevron-down"
|
||||
:size="14"
|
||||
class="flex-shrink-0 text-[var(--p-text-muted-color)]"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- PrimeVue Popover — replaces crewli-starter's manual document.mousedown click-outside -->
|
||||
<Popover ref="popoverRef">
|
||||
<!-- popover-head: px-[16px] py-[14px], border-bottom, flex, items-center, justify-between -->
|
||||
<div class="flex items-center justify-between border-b border-[var(--p-content-border-color)] px-[16px] py-[14px]">
|
||||
<!-- .popover-head .title -->
|
||||
<span class="text-[15px] font-bold tracking-[-0.01em]">Workspaces</span>
|
||||
<!-- .popover-head .link — TODO TECH-WS-GUI-REDESIGN: no manage-workspaces route yet -->
|
||||
<span class="cursor-pointer text-[13px] font-medium text-[var(--p-primary-color)]">Manage</span>
|
||||
</div>
|
||||
|
||||
<!-- .pop-ws .list: p-[6px] -->
|
||||
<div class="min-w-[280px] p-[6px]">
|
||||
<!-- .pop-ws .opt: grid 3-col, gap, p, rounded, cursor-pointer -->
|
||||
<div
|
||||
v-for="ws in allOrgs"
|
||||
:key="ws.id"
|
||||
class="grid cursor-pointer items-center gap-[12px] rounded-[var(--p-border-radius)] p-[10px] hover:bg-[var(--p-content-hover-background)]"
|
||||
:class="[
|
||||
ws.id === current?.id ? 'bg-[var(--p-primary-50)]' : '',
|
||||
]"
|
||||
style="grid-template-columns: 36px 1fr auto;"
|
||||
@click="selectOrg(ws)"
|
||||
>
|
||||
<!-- Org logo — larger variant (36px) with dynamic gradient (same inline-style justification as trigger) -->
|
||||
<span
|
||||
class="ws-logo-square-lg flex-shrink-0 rounded-[var(--p-border-radius)] inline-flex items-center justify-center text-white font-bold text-[13px]"
|
||||
:style="{ background: `linear-gradient(135deg, ${ws.gradient[0]}, ${ws.gradient[1]})` }"
|
||||
>{{ ws.initials }}</span>
|
||||
|
||||
<!-- Name + sub stack -->
|
||||
<span>
|
||||
<!-- .pop-ws .opt .name -->
|
||||
<div class="text-[14px] font-semibold text-[var(--p-text-color)]">{{ ws.name }}</div>
|
||||
<!-- .pop-ws .opt .sub -->
|
||||
<div class="mt-[2px] text-[12.5px] text-[var(--p-text-muted-color)]">{{ ws.sub }}</div>
|
||||
</span>
|
||||
|
||||
<!-- Check mark for active org (.pop-ws .opt .check-mark) -->
|
||||
<Icon
|
||||
v-if="ws.id === current?.id"
|
||||
name="tabler-check"
|
||||
:size="18"
|
||||
class="text-[var(--p-primary-color)]"
|
||||
/>
|
||||
<!-- Spacer when not current (keeps grid alignment) -->
|
||||
<span
|
||||
v-else
|
||||
class="w-[18px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer (.pop-ws .foot: p-[8px], border-top, flex, gap-[4px]) -->
|
||||
<div class="flex gap-[4px] border-t border-[var(--p-content-border-color)] p-[8px]">
|
||||
<!-- TODO TECH-WS-GUI-REDESIGN: create-workspace route not yet defined -->
|
||||
<button class="inline-flex flex-1 h-[36px] items-center justify-center gap-[6px] rounded-[var(--p-border-radius)] border-0 bg-transparent text-[13px] font-medium text-[var(--p-text-color)] hover:bg-[var(--p-content-hover-background)]">
|
||||
<Icon
|
||||
name="tabler-plus"
|
||||
:size="14"
|
||||
/>
|
||||
New workspace
|
||||
</button>
|
||||
<!-- TODO TECH-WS-GUI-REDESIGN: invite route not yet defined -->
|
||||
<button class="inline-flex flex-1 h-[36px] items-center justify-center gap-[6px] rounded-[var(--p-border-radius)] border-0 bg-transparent text-[13px] font-medium text-[var(--p-text-color)] hover:bg-[var(--p-content-hover-background)]">
|
||||
<Icon
|
||||
name="tabler-user-plus"
|
||||
:size="14"
|
||||
/>
|
||||
Invite
|
||||
</button>
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/**
|
||||
* Two layout exceptions that Tailwind cannot express as static utilities:
|
||||
*
|
||||
* 1. ws-logo-square: fixed 32×32px with inset box-shadow.
|
||||
* Tailwind has w-8/h-8 but no built-in `box-shadow: inset 0 -2px 0 rgba(0,0,0,0.10)`.
|
||||
* The background gradient is already handled via inline :style (dynamic hex pair).
|
||||
*/
|
||||
.ws-logo-square {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
box-shadow: inset 0 -2px 0 rgb(0 0 0 / 10%);
|
||||
}
|
||||
|
||||
/**
|
||||
* 2. ws-logo-square-lg: larger 36×36px variant used in the dropdown list.
|
||||
* Same box-shadow exception as above (inset shadow has no Tailwind equivalent).
|
||||
* Background gradient set via inline :style on the element (dynamic hex pair).
|
||||
*/
|
||||
.ws-logo-square-lg {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
box-shadow: inset 0 -2px 0 rgb(0 0 0 / 10%);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user