feat: extract QueenProfilePanel and open it from the app header

This commit is contained in:
bryan
2026-04-15 15:45:20 -07:00
parent deeb7de800
commit 70e3eb539b
4 changed files with 277 additions and 212 deletions
+31 -10
View File
@@ -7,7 +7,11 @@ import { Crown, KeyRound, Network } from "lucide-react";
import SettingsModal from "@/components/SettingsModal";
import ModelSwitcher from "@/components/ModelSwitcher";
export default function AppHeader() {
interface AppHeaderProps {
onOpenQueenProfile?: (queenId: string) => void;
}
export default function AppHeader({ onOpenQueenProfile }: AppHeaderProps) {
const location = useLocation();
const { colonies, queens, queenProfiles, userProfile } = useColony();
const { actions } = useHeaderActions();
@@ -21,6 +25,7 @@ export default function AppHeader() {
let title = "OpenHive";
let icon: React.ReactNode = null;
let queenTitle: string | null = null;
let queenIdForProfile: string | null = null;
if (colonyMatch) {
const colonyId = colonyMatch[1];
@@ -34,6 +39,8 @@ export default function AppHeader() {
title = profile?.name ?? queen?.name ?? queenInfo.name;
queenTitle = profile?.title ?? queen?.role ?? queenInfo.role;
icon = <Crown className="w-4 h-4 text-primary" />;
// Only enable the profile popup when we have a real profile to show.
if (profile) queenIdForProfile = profile.id;
} else if (location.pathname === "/org-chart") {
title = "Org Chart";
icon = <Network className="w-4 h-4 text-muted-foreground/60" />;
@@ -51,18 +58,32 @@ export default function AppHeader() {
.toUpperCase()
.slice(0, 2);
const queenHeaderContent = (
<>
{icon}
<h1 className="text-sm font-semibold text-foreground">{title}</h1>
{queenTitle && (
<span className="inline-flex items-center rounded-full border border-primary/20 bg-primary/10 px-2.5 py-1 text-[11px] font-medium text-primary shadow-sm">
{queenTitle}
</span>
)}
</>
);
return (
<>
<div className="relative z-20 h-12 flex items-center justify-between px-5 border-b border-border/60 bg-card/50 backdrop-blur-sm flex-shrink-0">
<div className="flex items-center gap-2">
{icon}
<h1 className="text-sm font-semibold text-foreground">{title}</h1>
{queenTitle && (
<span className="inline-flex items-center rounded-full border border-primary/20 bg-primary/10 px-2.5 py-1 text-[11px] font-medium text-primary shadow-sm">
{queenTitle}
</span>
)}
</div>
{queenIdForProfile ? (
<button
onClick={() => onOpenQueenProfile?.(queenIdForProfile!)}
className="flex items-center gap-2 rounded-md px-1.5 -mx-1.5 py-0.5 hover:bg-muted/60 transition-colors"
title={`View ${title}'s profile`}
>
{queenHeaderContent}
</button>
) : (
<div className="flex items-center gap-2">{queenHeaderContent}</div>
)}
<div className="flex items-center gap-2">
{actions}
<ModelSwitcher
@@ -0,0 +1,202 @@
import { useState, useEffect } from "react";
import { NavLink, useLocation, useNavigate } from "react-router-dom";
import {
X,
MessageSquare,
Crown,
ChevronRight,
Briefcase,
Award,
} from "lucide-react";
import { useColony } from "@/context/ColonyContext";
import { queensApi, type QueenProfile } from "@/api/queens";
import type { Colony } from "@/types/colony";
interface QueenProfilePanelProps {
queenId: string;
colonies: Colony[];
onClose: () => void;
}
export default function QueenProfilePanel({
queenId,
colonies,
onClose,
}: QueenProfilePanelProps) {
const navigate = useNavigate();
const location = useLocation();
const { queenProfiles } = useColony();
const summary = queenProfiles.find((q) => q.id === queenId);
const [profile, setProfile] = useState<QueenProfile | null>(null);
const [loading, setLoading] = useState(true);
// Hide the "Message {name}" button when we're already in this queen's PM.
const alreadyInQueenPm = location.pathname === `/queen/${queenId}`;
useEffect(() => {
setLoading(true);
setProfile(null);
queensApi
.getProfile(queenId)
.then(setProfile)
.catch(() => {})
.finally(() => setLoading(false));
}, [queenId]);
const name = profile?.name ?? summary?.name ?? "Queen";
const title = profile?.title ?? summary?.title ?? "";
return (
<aside className="w-[340px] flex-shrink-0 border-l border-border/60 bg-card overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between px-5 py-3.5 border-b border-border/60">
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
<Crown className="w-4 h-4 text-primary" />
QUEEN PROFILE
</div>
<button
onClick={onClose}
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="px-5 py-6">
{loading ? (
<div className="flex justify-center py-10">
<div className="w-6 h-6 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
</div>
) : (
<>
{/* Avatar + name + title */}
<div className="flex flex-col items-center text-center mb-6">
<div className="w-16 h-16 rounded-full bg-primary/15 flex items-center justify-center mb-3">
<span className="text-xl font-bold text-primary">
{name.charAt(0)}
</span>
</div>
<h3 className="text-base font-semibold text-foreground">
{name}
</h3>
<p className="text-xs text-muted-foreground mt-0.5">{title}</p>
</div>
{/* Message button — hidden when already in this queen's PM */}
{!alreadyInQueenPm && (
<button
onClick={() => {
navigate(`/queen/${queenId}`);
onClose();
}}
className="w-full flex items-center justify-center gap-2 rounded-lg border border-border/60 py-2.5 text-sm font-medium text-foreground hover:bg-muted/40 transition-colors mb-6"
>
<MessageSquare className="w-4 h-4" />
Message {name}
</button>
)}
{/* About */}
{profile?.summary && (
<div className="mb-6">
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">
About
</h4>
<p className="text-sm text-foreground/80 leading-relaxed">
{profile.summary}
</p>
</div>
)}
{/* Experience */}
{profile?.experience && profile.experience.length > 0 && (
<div className="mb-6">
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Experience
</h4>
<div className="space-y-3">
{profile.experience.map((exp, i) => (
<div key={i} className="flex items-start gap-2">
<Briefcase className="w-3.5 h-3.5 text-muted-foreground mt-0.5 flex-shrink-0" />
<div>
<p className="text-sm font-medium text-foreground">
{exp.role}
</p>
<ul className="mt-1 space-y-0.5">
{exp.details.map((d, j) => (
<li
key={j}
className="text-xs text-muted-foreground"
>
{d}
</li>
))}
</ul>
</div>
</div>
))}
</div>
</div>
)}
{/* Skills */}
{profile?.skills && (
<div className="mb-6">
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Skills
</h4>
<div className="flex flex-wrap gap-1.5">
{profile.skills.split(",").map((skill, i) => (
<span
key={i}
className="px-2 py-0.5 rounded-full bg-muted/60 text-xs text-muted-foreground"
>
{skill.trim()}
</span>
))}
</div>
</div>
)}
{/* Signature achievement */}
{profile?.signature_achievement && (
<div className="mb-6">
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Signature Achievement
</h4>
<div className="flex items-start gap-2">
<Award className="w-3.5 h-3.5 text-primary mt-0.5 flex-shrink-0" />
<p className="text-sm text-foreground/80">
{profile.signature_achievement}
</p>
</div>
</div>
)}
{/* Assigned colonies */}
{colonies.length > 0 && (
<div>
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Assigned Colonies
</h4>
<div className="flex flex-col gap-1.5">
{colonies.map((colony) => (
<NavLink
key={colony.id}
to={`/colony/${colony.id}`}
onClick={onClose}
className="flex items-center justify-between rounded-lg border border-primary/20 bg-primary/[0.04] px-3 py-2 text-sm text-primary hover:bg-primary/[0.08] transition-colors"
>
<span className="font-medium">#{colony.id}</span>
<ChevronRight className="w-3.5 h-3.5" />
</NavLink>
))}
</div>
</div>
)}
</>
)}
</div>
</aside>
);
}
+40 -11
View File
@@ -1,23 +1,52 @@
import { Outlet } from "react-router-dom";
import { useEffect, useState } from "react";
import { Outlet, useLocation } from "react-router-dom";
import Sidebar from "@/components/Sidebar";
import AppHeader from "@/components/AppHeader";
import { ColonyProvider } from "@/context/ColonyContext";
import QueenProfilePanel from "@/components/QueenProfilePanel";
import { ColonyProvider, useColony } from "@/context/ColonyContext";
import { HeaderActionsProvider } from "@/context/HeaderActionsContext";
export default function AppLayout() {
return (
<ColonyProvider>
<HeaderActionsProvider>
<div className="flex h-screen bg-background overflow-hidden">
<Sidebar />
<div className="flex-1 min-w-0 flex flex-col">
<AppHeader />
<main className="flex-1 min-h-0 flex flex-col">
<Outlet />
</main>
</div>
</div>
<AppLayoutInner />
</HeaderActionsProvider>
</ColonyProvider>
);
}
function AppLayoutInner() {
const { colonies } = useColony();
const location = useLocation();
const [openQueenId, setOpenQueenId] = useState<string | null>(null);
// Close the profile panel whenever the route changes so it doesn't
// bleed across pages (the panel state lives at the layout level).
useEffect(() => {
setOpenQueenId(null);
}, [location.pathname]);
return (
<div className="flex h-screen bg-background overflow-hidden">
<Sidebar />
<div className="flex-1 min-w-0 flex flex-col">
<AppHeader onOpenQueenProfile={setOpenQueenId} />
<div className="flex-1 min-h-0 flex">
<main className="flex-1 min-w-0 flex flex-col">
<Outlet />
</main>
{openQueenId && (
<QueenProfilePanel
queenId={openQueenId}
colonies={colonies.filter(
(c) => c.queenProfileId === openQueenId,
)}
onClose={() => setOpenQueenId(null)}
/>
)}
</div>
</div>
</div>
);
}
+4 -191
View File
@@ -1,18 +1,10 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { NavLink, useNavigate } from "react-router-dom";
import {
User,
X,
MessageSquare,
Crown,
ChevronRight,
Briefcase,
Award,
} from "lucide-react";
import { useState, useCallback, useRef } from "react";
import { NavLink } from "react-router-dom";
import { User } from "lucide-react";
import { useColony } from "@/context/ColonyContext";
import { queensApi, type QueenProfile } from "@/api/queens";
import type { QueenProfileSummary, Colony } from "@/types/colony";
import { getColonyIcon } from "@/lib/colony-registry";
import QueenProfilePanel from "@/components/QueenProfilePanel";
/* ── Colony tag (clickable link to colony chat) ───────────────────────── */
@@ -84,185 +76,6 @@ function QueenCard({
);
}
/* ── Queen profile side panel ─────────────────────────────────────────── */
function QueenProfilePanel({
queenId,
colonies,
onClose,
}: {
queenId: string;
colonies: Colony[];
onClose: () => void;
}) {
const navigate = useNavigate();
const { queenProfiles } = useColony();
const summary = queenProfiles.find((q) => q.id === queenId);
const [profile, setProfile] = useState<QueenProfile | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
setProfile(null);
queensApi
.getProfile(queenId)
.then(setProfile)
.catch(() => {})
.finally(() => setLoading(false));
}, [queenId]);
const name = profile?.name ?? summary?.name ?? "Queen";
const title = profile?.title ?? summary?.title ?? "";
return (
<aside className="w-[340px] flex-shrink-0 border-l border-border/60 bg-card overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between px-5 py-3.5 border-b border-border/60">
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
<Crown className="w-4 h-4 text-primary" />
QUEEN PROFILE
</div>
<button
onClick={onClose}
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="px-5 py-6">
{loading ? (
<div className="flex justify-center py-10">
<div className="w-6 h-6 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
</div>
) : (
<>
{/* Avatar + name + title */}
<div className="flex flex-col items-center text-center mb-6">
<div className="w-16 h-16 rounded-full bg-primary/15 flex items-center justify-center mb-3">
<span className="text-xl font-bold text-primary">
{name.charAt(0)}
</span>
</div>
<h3 className="text-base font-semibold text-foreground">
{name}
</h3>
<p className="text-xs text-muted-foreground mt-0.5">{title}</p>
</div>
{/* Message button */}
<button
onClick={() => navigate(`/queen/${queenId}`)}
className="w-full flex items-center justify-center gap-2 rounded-lg border border-border/60 py-2.5 text-sm font-medium text-foreground hover:bg-muted/40 transition-colors mb-6"
>
<MessageSquare className="w-4 h-4" />
Message {name}
</button>
{/* About */}
{profile?.summary && (
<div className="mb-6">
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">
About
</h4>
<p className="text-sm text-foreground/80 leading-relaxed">
{profile.summary}
</p>
</div>
)}
{/* Experience */}
{profile?.experience && profile.experience.length > 0 && (
<div className="mb-6">
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Experience
</h4>
<div className="space-y-3">
{profile.experience.map((exp, i) => (
<div key={i} className="flex items-start gap-2">
<Briefcase className="w-3.5 h-3.5 text-muted-foreground mt-0.5 flex-shrink-0" />
<div>
<p className="text-sm font-medium text-foreground">
{exp.role}
</p>
<ul className="mt-1 space-y-0.5">
{exp.details.map((d, j) => (
<li
key={j}
className="text-xs text-muted-foreground"
>
{d}
</li>
))}
</ul>
</div>
</div>
))}
</div>
</div>
)}
{/* Skills */}
{profile?.skills && (
<div className="mb-6">
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Skills
</h4>
<div className="flex flex-wrap gap-1.5">
{profile.skills.split(",").map((skill, i) => (
<span
key={i}
className="px-2 py-0.5 rounded-full bg-muted/60 text-xs text-muted-foreground"
>
{skill.trim()}
</span>
))}
</div>
</div>
)}
{/* Signature achievement */}
{profile?.signature_achievement && (
<div className="mb-6">
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Signature Achievement
</h4>
<div className="flex items-start gap-2">
<Award className="w-3.5 h-3.5 text-primary mt-0.5 flex-shrink-0" />
<p className="text-sm text-foreground/80">
{profile.signature_achievement}
</p>
</div>
</div>
)}
{/* Assigned colonies */}
{colonies.length > 0 && (
<div>
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Assigned Colonies
</h4>
<div className="flex flex-col gap-1.5">
{colonies.map((colony) => (
<NavLink
key={colony.id}
to={`/colony/${colony.id}`}
className="flex items-center justify-between rounded-lg border border-primary/20 bg-primary/[0.04] px-3 py-2 text-sm text-primary hover:bg-primary/[0.08] transition-colors"
>
<span className="font-medium">#{colony.id}</span>
<ChevronRight className="w-3.5 h-3.5" />
</NavLink>
))}
</div>
</div>
)}
</>
)}
</div>
</aside>
);
}
/* ── Main org chart page ──────────────────────────────────────────────── */
export default function OrgChart() {