feat: extract QueenProfilePanel and open it from the app header
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user