Files
crewli/apps/app/src/components-v2/layout/WorkspaceSwitcher.vue
bert.hausmans 3720e8c3d3 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>
2026-05-16 19:48:50 +02:00

257 lines
9.5 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>