mirror of
https://github.com/GlueOps/autoglue.git
synced 2026-02-13 12:50:05 +01:00
feat: sdk migration in progress
This commit is contained in:
157
ui/src/layouts/app-shell.tsx
Normal file
157
ui/src/layouts/app-shell.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { meApi } from "@/api/me.ts"
|
||||
import { orgStore } from "@/auth/org.ts"
|
||||
import { authStore } from "@/auth/store.ts"
|
||||
import { mainNav, orgNav, userNav } from "@/layouts/nav-config.ts"
|
||||
import { OrgSwitcher } from "@/layouts/org-switcher.tsx"
|
||||
import { Topbar } from "@/layouts/topbar.tsx"
|
||||
import { NavLink, Outlet } from "react-router-dom"
|
||||
|
||||
import { cn } from "@/lib/utils.ts"
|
||||
import { Button } from "@/components/ui/button.tsx"
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarProvider,
|
||||
} from "@/components/ui/sidebar.tsx"
|
||||
|
||||
type Org = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export const AppShell = () => {
|
||||
const [orgs, setOrgs] = useState<Org[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
;(async () => {
|
||||
try {
|
||||
const me = await meApi.getMe() // HandlersMeResponse
|
||||
const list = (me.organizations ?? []).map((o) => ({
|
||||
id: o.id,
|
||||
name: o.name ?? o.id,
|
||||
}))
|
||||
if (!alive) return
|
||||
setOrgs(list as Org[])
|
||||
|
||||
// default selection if none
|
||||
if (!orgStore.get() && list.length > 0) {
|
||||
orgStore.set(list[0].id!)
|
||||
}
|
||||
} catch {
|
||||
// ignore; ProtectedRoute will handle auth
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<SidebarProvider defaultOpen>
|
||||
<Sidebar collapsible="icon" variant="floating">
|
||||
<SidebarHeader>
|
||||
<div className="px-2 py-2">
|
||||
<OrgSwitcher orgs={orgs} />
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Navigation</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{mainNav.map((n) => (
|
||||
<SidebarMenuItem key={n.to}>
|
||||
<SidebarMenuButton asChild tooltip={n.label}>
|
||||
<NavLink
|
||||
to={n.to}
|
||||
className={({ isActive }) =>
|
||||
cn("flex items-center gap-2", isActive && "text-primary")
|
||||
}
|
||||
>
|
||||
<n.icon className="h-4 w-4" />
|
||||
<span>{n.label}</span>
|
||||
</NavLink>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Organization</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{orgNav.map((n) => (
|
||||
<SidebarMenuItem key={n.to}>
|
||||
<SidebarMenuButton asChild tooltip={n.label}>
|
||||
<NavLink
|
||||
to={n.to}
|
||||
className={({ isActive }) =>
|
||||
cn("flex items-center gap-2", isActive && "text-primary")
|
||||
}
|
||||
>
|
||||
<n.icon className="h-4 w-4" />
|
||||
<span>{n.label}</span>
|
||||
</NavLink>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>User</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{userNav.map((n) => (
|
||||
<SidebarMenuItem key={n.to}>
|
||||
<SidebarMenuButton asChild tooltip={n.label}>
|
||||
<NavLink
|
||||
to={n.to}
|
||||
className={({ isActive }) =>
|
||||
cn("flex items-center gap-2", isActive && "text-primary")
|
||||
}
|
||||
>
|
||||
<n.icon className="h-4 w-4" />
|
||||
<span>{n.label}</span>
|
||||
</NavLink>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter>
|
||||
<div className="px-2 py-2">
|
||||
<Button variant="ghost" size="sm" className="w-full" onClick={() => authStore.logout()}>
|
||||
Sign out
|
||||
</Button>
|
||||
</div>
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
|
||||
<SidebarInset className="min-h-screen">
|
||||
<Topbar />
|
||||
<main className="p-4">
|
||||
<Outlet />
|
||||
</main>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
40
ui/src/layouts/nav-config.ts
Normal file
40
ui/src/layouts/nav-config.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { ComponentType } from "react"
|
||||
import {
|
||||
BoxesIcon,
|
||||
Building2,
|
||||
ComponentIcon,
|
||||
FileKey2Icon,
|
||||
KeyRound,
|
||||
ServerIcon,
|
||||
SprayCanIcon,
|
||||
TagsIcon,
|
||||
User2,
|
||||
Users,
|
||||
} from "lucide-react"
|
||||
import { AiOutlineCluster } from "react-icons/ai"
|
||||
import { GrUserWorker } from "react-icons/gr"
|
||||
|
||||
export type NavItem = {
|
||||
to: string
|
||||
label: string
|
||||
icon: ComponentType<{ className?: string }>
|
||||
}
|
||||
|
||||
export const mainNav: NavItem[] = [
|
||||
{ to: "/clusters", label: "Clusters", icon: AiOutlineCluster },
|
||||
{ to: "/node-pools", label: "Node Pools", icon: BoxesIcon },
|
||||
{ to: "/annotations", label: "Annotations", icon: ComponentIcon },
|
||||
{ to: "/Labels", label: "Labels", icon: TagsIcon },
|
||||
{ to: "/taints", label: "Taints", icon: SprayCanIcon },
|
||||
{ to: "/servers", label: "Servers", icon: ServerIcon },
|
||||
{ to: "/ssh", label: "SSH Keys", icon: FileKey2Icon },
|
||||
{ to: "/jobs", label: "Jobs", icon: GrUserWorker },
|
||||
]
|
||||
|
||||
export const orgNav: NavItem[] = [
|
||||
{ to: "/org/members", label: "Members", icon: Users },
|
||||
{ to: "/org/api-keys", label: "Org API Keys", icon: KeyRound },
|
||||
{ to: "/org/settings", label: "Org Settings", icon: Building2 },
|
||||
]
|
||||
|
||||
export const userNav: NavItem[] = [{ to: "/me", label: "Profile", icon: User2 }]
|
||||
71
ui/src/layouts/org-switcher.tsx
Normal file
71
ui/src/layouts/org-switcher.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { orgStore } from "@/auth/org.ts"
|
||||
import { Building2, Check, ChevronsUpDown } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils.ts"
|
||||
import { Button } from "@/components/ui/button.tsx"
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command.tsx"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover.tsx"
|
||||
|
||||
type Org = { id: string; name: string }
|
||||
|
||||
export const OrgSwitcher = ({ orgs }: { orgs: Org[] }) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [value, setValue] = useState(orgStore.get() ?? "")
|
||||
|
||||
useEffect(() => {
|
||||
return orgStore.subscribe((id) => setValue(id ?? ""))
|
||||
}, [])
|
||||
|
||||
const selected = orgs.find((o) => o.id === value)
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-9 w-full justify-between px-2"
|
||||
aria-label="Switch organization"
|
||||
>
|
||||
<span className="flex items-center gap-2 truncate">
|
||||
<Building2 className="h-4 w-4" />
|
||||
<span className="truncate">{selected?.name ?? "Select org"}</span>
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[280px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search orgs..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No orgs found.</CommandEmpty>
|
||||
<CommandGroup heading="Organizations">
|
||||
{orgs.map((org) => (
|
||||
<CommandItem
|
||||
key={org.id}
|
||||
value={org.id}
|
||||
onSelect={(v) => {
|
||||
orgStore.set(v)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn("mr-2 h-4 w-4", value === org.id ? "opacity-100" : "opacity-0")}
|
||||
/>
|
||||
<span className="truncate">{org.name}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
81
ui/src/layouts/topbar.tsx
Normal file
81
ui/src/layouts/topbar.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useMemo } from "react"
|
||||
import { Link, useLocation } from "react-router-dom"
|
||||
|
||||
import { useMe } from "@/hooks/use-me.ts"
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar.tsx"
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb.tsx"
|
||||
import { Button } from "@/components/ui/button.tsx"
|
||||
import { SidebarTrigger } from "@/components/ui/sidebar.tsx"
|
||||
|
||||
export const Topbar = () => {
|
||||
const loc = useLocation()
|
||||
const { data: me, isLoading } = useMe()
|
||||
|
||||
const crumbs = useMemo(() => {
|
||||
const parts = loc.pathname.split("/").filter(Boolean)
|
||||
const acc: { to: string; label: string }[] = []
|
||||
let build = ""
|
||||
for (const p of parts) {
|
||||
build += `/${p}`
|
||||
acc.push({ to: build, label: p })
|
||||
}
|
||||
return acc
|
||||
}, [loc.pathname])
|
||||
|
||||
const initials = useMemo(() => {
|
||||
if (!me) return "U"
|
||||
const name = me.display_name || me.primary_email || ""
|
||||
const parts = name.trim().split(/\s+/)
|
||||
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase()
|
||||
if (parts.length === 1 && parts[0]) return parts[0][0]!.toUpperCase()
|
||||
return "U"
|
||||
}, [me])
|
||||
|
||||
return (
|
||||
<div className="flex h-12 items-center gap-2 border-b px-3">
|
||||
<SidebarTrigger />
|
||||
<div className="flex-1">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link to="/">Home</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
{crumbs.map((c, i) => (
|
||||
<span key={c.to} className="flex items-center">
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
{i === crumbs.length - 1 ? (
|
||||
<BreadcrumbPage className="capitalize">{c.label}</BreadcrumbPage>
|
||||
) : (
|
||||
<BreadcrumbLink asChild>
|
||||
<Link to={c.to} className="capitalize">
|
||||
{c.label}
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
</span>
|
||||
))}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link to="/me">{isLoading ? "…" : me?.display_name || "Profile"}</Link>
|
||||
</Button>
|
||||
|
||||
<Avatar className="h-7 w-7">
|
||||
<AvatarFallback>{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user