| 1 | /** |
| 2 | * Pieces of People's pages: a person's face with their presence, an |
| 3 | * agent's with its status, someone's local time, what a team is made of, |
| 4 | * and a label for what is coming. |
| 5 | */ |
| 6 | import { useEffect, useState } from "react"; |
| 7 | import { Link } from "react-router"; |
| 8 | |
| 9 | import { type DirectoryPerson, type TeamKind, teamKind } from "@g1t/contracts"; |
| 10 | |
| 11 | import { AgentFace } from "./agents-mode"; |
| 12 | import { StatusDot } from "./chat/marks"; |
| 13 | import { WithPresence } from "./presence"; |
| 14 | |
| 15 | import { Avatar } from "./ui/avatar"; |
| 16 | import { Badge } from "./ui/badge"; |
| 17 | import { Hint } from "./ui/hint"; |
| 18 | import { cn } from "../lib/cn"; |
| 19 | import { type PeopleAgent, agentPath, personName, personPath } from "../lib/people"; |
| 20 | import { localTime } from "../lib/time-zone"; |
| 21 | |
| 22 | /** A person's avatar, with their presence in its corner. */ |
| 23 | export function PersonFace({ person, size = 28, ring }: { person: Pick<DirectoryPerson, "user_id" | "username" | "avatar">; size?: number; ring?: string }) { |
| 24 | return ( |
| 25 | <WithPresence person={{ id: person.user_id, username: person.username }} size={size} ring={ring}> |
| 26 | <Avatar name={person.username} image={person.avatar} size={size} /> |
| 27 | </WithPresence> |
| 28 | ); |
| 29 | } |
| 30 | |
| 31 | /** An agent's face, with its status in its corner. */ |
| 32 | export function AgentPersonFace({ agent, size = 28, ring = "var(--color-bg)" }: { agent: PeopleAgent; size?: number; ring?: string }) { |
| 33 | return ( |
| 34 | <span className="relative inline-flex shrink-0"> |
| 35 | <AgentFace agent={agent} size={size} /> |
| 36 | <span className="absolute -right-0.5 -bottom-0.5 inline-flex rounded-full" style={{ boxShadow: `0 0 0 2px ${ring}` }}> |
| 37 | <StatusDot status={agent.status} className="block" /> |
| 38 | </span> |
| 39 | </span> |
| 40 | ); |
| 41 | } |
| 42 | |
| 43 | /** |
| 44 | * Someone's local time, worked out in the browser (the server's clock is |
| 45 | * not theirs), and kept current. Nothing when they gave no time zone. |
| 46 | */ |
| 47 | export function LocalTime({ zone, className, suffix = "local" }: { zone: string | null; className?: string; suffix?: string }) { |
| 48 | const [now, setNow] = useState<number | null>(null); |
| 49 | useEffect(() => { |
| 50 | setNow(Date.now()); |
| 51 | const timer = setInterval(() => setNow(Date.now()), 30_000); |
| 52 | return () => clearInterval(timer); |
| 53 | }, []); |
| 54 | if (!zone || now == null) return null; |
| 55 | const time = localTime(zone, now); |
| 56 | if (!time) return null; |
| 57 | return ( |
| 58 | <span className={className}> |
| 59 | {time} {suffix} |
| 60 | </span> |
| 61 | ); |
| 62 | } |
| 63 | |
| 64 | const KIND_MARK: Record<TeamKind, string> = { |
| 65 | mixed: "bg-[linear-gradient(135deg,var(--color-success)_50%,var(--color-accent)_50%)]", |
| 66 | people: "bg-success", |
| 67 | agents: "bg-accent", |
| 68 | empty: "bg-line-strong", |
| 69 | }; |
| 70 | |
| 71 | const KIND_WORDS: Record<TeamKind, string> = { |
| 72 | mixed: "People and agents", |
| 73 | people: "People only", |
| 74 | agents: "Agents only", |
| 75 | empty: "No one yet", |
| 76 | }; |
| 77 | |
| 78 | /** A small square that says what a team is made of: people, agents, or both. */ |
| 79 | export function TeamKindMark({ people, agents, className }: { people: number; agents: number; className?: string }) { |
| 80 | const kind = teamKind(people, agents); |
| 81 | return ( |
| 82 | <Hint label={KIND_WORDS[kind]}> |
| 83 | <span tabIndex={0} className={cn("inline-block size-2.5 shrink-0 rounded-[3px]", KIND_MARK[kind], className)}> |
| 84 | <span className="sr-only">{KIND_WORDS[kind]}</span> |
| 85 | </span> |
| 86 | </Hint> |
| 87 | ); |
| 88 | } |
| 89 | |
| 90 | /** Something that is planned and not here yet. */ |
| 91 | export function Coming({ children = "Coming" }: { children?: string }) { |
| 92 | return <Badge className="border-dashed">{children}</Badge>; |
| 93 | } |
| 94 | |
| 95 | /** A person's name, linked to their profile. */ |
| 96 | export function PersonLink({ workspace, person, className }: { workspace: string; person: Pick<DirectoryPerson, "username" | "name" | "display_username">; className?: string }) { |
| 97 | return ( |
| 98 | <Link to={personPath(workspace, person.username)} prefetch="intent" className={cn("hover:text-accent", className)}> |
| 99 | {personName(person)} |
| 100 | </Link> |
| 101 | ); |
| 102 | } |
| 103 | |
| 104 | /** An agent's name, linked to its People profile. */ |
| 105 | export function AgentLink({ workspace, agent, className }: { workspace: string; agent: Pick<PeopleAgent, "handle" | "display_name">; className?: string }) { |
| 106 | return ( |
| 107 | <Link to={agentPath(workspace, agent.handle)} prefetch="intent" className={cn("hover:text-accent", className)}> |
| 108 | {agent.display_name} |
| 109 | </Link> |
| 110 | ); |
| 111 | } |
| 112 | |
| 113 | /** The small "agent" label after an agent's name. */ |
| 114 | export function AgentTag() { |
| 115 | return <Badge tone="accent">Agent</Badge>; |
| 116 | } |
| 117 | |
| 118 | /** What the agent is told, as it reads: headings, lines and lists. */ |
| 119 | export function ToldText({ text }: { text: string }) { |
| 120 | const lines = text.split("\n"); |
| 121 | return ( |
| 122 | <div className="space-y-1.5 text-sm text-fg-soft"> |
| 123 | {lines.map((line, index) => { |
| 124 | if (line.startsWith("## ")) return null; |
| 125 | if (line.startsWith("### ")) { |
| 126 | return ( |
| 127 | <h3 key={index} className="pt-2 font-medium text-fg first:pt-0"> |
| 128 | {line.slice(4)} |
| 129 | </h3> |
| 130 | ); |
| 131 | } |
| 132 | if (line.startsWith("- ")) { |
| 133 | return ( |
| 134 | <p key={index} className="relative pl-4 before:absolute before:left-1 before:text-faint before:content-['•']"> |
| 135 | {line.slice(2)} |
| 136 | </p> |
| 137 | ); |
| 138 | } |
| 139 | if (!line.trim()) return null; |
| 140 | return <p key={index}>{line}</p>; |
| 141 | })} |
| 142 | </div> |
| 143 | ); |
| 144 | } |