mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-06 08:02:06 +00:00
155 lines
6.8 KiB
TypeScript
155 lines
6.8 KiB
TypeScript
// Org picker — moved off the shadcn DropdownMenu onto the same
|
|
// PopoverMenu primitive every other dropdown in the dashboard uses
|
|
// (folders, sort, accounts, schedule, sort). One animation, one
|
|
// surface, one set of styles.
|
|
//
|
|
// Sits in the sidebar header above the nav. Trigger is a slim h-7
|
|
// row: 18px slate-900 monogram tile, current org name, chevron.
|
|
// Hover greys the row; the active org in the popover gets a single
|
|
// faint slate-100 background — no big check mark, no avatar inside
|
|
// each row.
|
|
|
|
import React from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
import { ChevronDownIcon, PlusIcon, Settings2Icon } from "lucide-react";
|
|
import toast from "react-hot-toast";
|
|
import { useAppStore } from "@/stores";
|
|
import useSwitchOrganization from "@/lib/api/hooks/app/organizations/useSwitchOrganization";
|
|
import { NewWorkspaceDialog } from "@/components/app/organizations/NewWorkspaceDialog";
|
|
import type { AppError } from "@/lib/api/client/normalizeError";
|
|
import buildError from "@/lib/helper/buildError";
|
|
import {
|
|
PopoverMenu,
|
|
PopoverMenuContent,
|
|
PopoverMenuItem,
|
|
PopoverMenuLabel,
|
|
PopoverMenuSeparator,
|
|
PopoverMenuTrigger,
|
|
} from "@/components/ui/popover-menu";
|
|
|
|
function initials(name: string): string {
|
|
return name
|
|
.split(" ")
|
|
.filter(Boolean)
|
|
.map((w) => w[0])
|
|
.join("")
|
|
.toUpperCase()
|
|
.slice(0, 2);
|
|
}
|
|
|
|
export function OrgSwitcher() {
|
|
const navigate = useNavigate();
|
|
const organizations = useAppStore((s) => s.organizations);
|
|
const currentOrganization = useAppStore((s) => s.currentOrganization);
|
|
const setCurrentOrganization = useAppStore((s) => s.setCurrentOrganization);
|
|
const switchOrgMutation = useSwitchOrganization();
|
|
const [newOpen, setNewOpen] = React.useState(false);
|
|
|
|
const name = currentOrganization?.name ?? "Workspace";
|
|
const orgAvatar = currentOrganization?.avatar_url ?? currentOrganization?.avatar;
|
|
|
|
// Picking an org from the sidebar used to call only the local
|
|
// zustand action, which left the server session's
|
|
// `current_organization_id` at whatever it was before. Every
|
|
// org-scoped request would then 4xx with "no organization selected"
|
|
// while the UI happily showed the org as picked. Now we POST the
|
|
// switch first; only on success do we move the local pointer.
|
|
const handleSwitch = async (orgId: string) => {
|
|
if (orgId === currentOrganization?.id) return;
|
|
try {
|
|
await switchOrgMutation.mutateAsync(orgId);
|
|
const next = organizations.find((o) => o.id === orgId);
|
|
if (next) setCurrentOrganization(next);
|
|
} catch (e) {
|
|
toast.error(buildError(e as AppError));
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<PopoverMenu align="start">
|
|
<PopoverMenuTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="group w-full flex items-center gap-2 pl-1 pr-2 h-8 rounded-md hover:bg-slate-200/60 transition-colors text-left"
|
|
>
|
|
<span
|
|
className={`size-6 rounded-md ring-1 ring-slate-200 flex items-center justify-center shrink-0 overflow-hidden ${
|
|
orgAvatar ? "bg-white p-0.5" : "bg-slate-900"
|
|
}`}
|
|
>
|
|
{orgAvatar ? (
|
|
<img
|
|
src={orgAvatar}
|
|
alt=""
|
|
className="w-full h-full object-cover rounded-[4px]"
|
|
/>
|
|
) : (
|
|
<span className="text-[10px] font-bold text-white leading-none tracking-tight">
|
|
{initials(name)}
|
|
</span>
|
|
)}
|
|
</span>
|
|
<span className="text-[12.5px] font-medium text-slate-900 truncate flex-1 min-w-0">
|
|
{name}
|
|
</span>
|
|
<ChevronDownIcon className="w-3.5 h-3.5 text-slate-400 shrink-0 group-hover:text-slate-700 transition-colors" />
|
|
</button>
|
|
</PopoverMenuTrigger>
|
|
|
|
<PopoverMenuContent minWidth={232}>
|
|
<PopoverMenuLabel>Workspaces</PopoverMenuLabel>
|
|
{organizations.length === 0 ? (
|
|
<div className="px-3 py-2 text-[11.5px] text-slate-400">
|
|
No workspaces yet.
|
|
</div>
|
|
) : (
|
|
organizations.map((org) => {
|
|
const avatar = org.avatar_url ?? org.avatar;
|
|
return (
|
|
<PopoverMenuItem
|
|
key={org.id}
|
|
onSelect={() => handleSwitch(org.id)}
|
|
disabled={switchOrgMutation.isPending}
|
|
selected={org.id === currentOrganization?.id}
|
|
icon={
|
|
<span className={`size-5 rounded-md ring-1 ring-slate-200 flex items-center justify-center shrink-0 overflow-hidden ${avatar ? "bg-white p-px" : "bg-slate-900"}`}>
|
|
{avatar ? (
|
|
<img
|
|
src={avatar}
|
|
alt=""
|
|
className="w-full h-full object-cover rounded-[3px]"
|
|
/>
|
|
) : (
|
|
<span className="text-[9px] font-bold text-white leading-none tracking-tight">
|
|
{initials(org.name)}
|
|
</span>
|
|
)}
|
|
</span>
|
|
}
|
|
>
|
|
{org.name}
|
|
</PopoverMenuItem>
|
|
);
|
|
})
|
|
)}
|
|
<PopoverMenuSeparator />
|
|
<PopoverMenuItem
|
|
onSelect={() => setNewOpen(true)}
|
|
icon={<PlusIcon className="w-3 h-3" />}
|
|
>
|
|
New workspace
|
|
</PopoverMenuItem>
|
|
<PopoverMenuItem
|
|
onSelect={() => navigate("/select-org")}
|
|
icon={<Settings2Icon className="w-3 h-3" />}
|
|
>
|
|
Manage workspaces
|
|
</PopoverMenuItem>
|
|
</PopoverMenuContent>
|
|
</PopoverMenu>
|
|
<NewWorkspaceDialog open={newOpen} onClose={() => setNewOpen(false)} />
|
|
</>
|
|
);
|
|
}
|