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