Skip to content
150 linesCodeBlameRaw
1/**
2 * Pieces Artifacts' pages share: a face for a person or an agent, who an
3 * artifact is shared with, breadcrumbs, a space's icon, a section heading,
4 * a template card.
5 */
6import { FOLIO_SPACE_KIND_LABELS, type DocSpaceKind, type Folio, type FolioTemplate, type MemberProfile } from "@g1t/contracts";
7import { ChevronRight, Globe, Library, Link2, Lock, Users } from "lucide-react";
8import type { ReactNode } from "react";
9import { Link } from "react-router";
10
11import { AgentAvatar } from "../agent-avatar";
12
13import { Avatar } from "../ui/avatar";
14import { Card } from "../ui/card";
15import { Hint } from "../ui/hint";
16import { KindIcon } from "./kinds";
17
18export function Face({ who, size = 20 }: { who: Pick<MemberProfile, "kind" | "id" | "name" | "avatar" | "avatar_seed">; size?: number }) {
19 if (who.kind === "agent") return <AgentAvatar agent={{ id: who.id, handle: who.name, avatar: who.avatar, avatar_seed: who.avatar_seed ?? null }} size={size} />;
20 return <Avatar name={who.name} image={who.avatar} size={size} />;
21}
22
23export function Faces({ people, size = 22, max = 4 }: { people: MemberProfile[]; size?: number; max?: number }) {
24 const shown = people.slice(0, max);
25 return (
26 <span className="flex items-center -space-x-1.5">
27 {shown.map((p) => (
28 <Hint key={`${p.kind}:${p.id}`} label={p.display_name}>
29 <span className="rounded-full ring-2 ring-bg">
30 <Face who={p} size={size} />
31 </span>
32 </Hint>
33 ))}
34 {people.length > max && <span className="ml-2 text-xs text-faint">+{people.length - max}</span>}
35 </span>
36 );
37}
38
39/** A space's icon: its emoji, or one for who it's for. */
40export function SpaceIcon({ space, size = 15 }: { space: { icon: string | null; kind: DocSpaceKind }; size?: number }) {
41 if (space.icon) {
42 return (
43 <span className="leading-none" style={{ fontSize: size - 1 }} aria-hidden="true">
44 {space.icon}
45 </span>
46 );
47 }
48 if (space.kind === "private") return <Lock size={size} className="text-faint" aria-hidden="true" />;
49 if (space.kind === "team") return <Users size={size} className="text-faint" aria-hidden="true" />;
50 return <Library size={size} className="text-faint" aria-hidden="true" />;
51}
52
53/** Who a space is for, in its words: Open, Team, Members only. */
54export function spaceKindLabel(kind: DocSpaceKind): string {
55 return FOLIO_SPACE_KIND_LABELS[kind];
56}
57
58/**
59 * Who can open an artifact, in a glance: a lock when only its owner can,
60 * else the way it is opened up (the workspace, a link) and how many it is
61 * shared with directly.
62 */
63export function AccessMark({ folio }: { folio: Pick<Folio, "private" | "general_access" | "shared_count" | "space" | "inherit"> }) {
64 if (folio.private) {
65 return (
66 <Hint label="Only you can see this">
67 <span className="flex size-5 items-center justify-center text-faint" aria-label="Only you can see this">
68 <Lock size={13} />
69 </span>
70 </Hint>
71 );
72 }
73 if (folio.general_access === "workspace") {
74 return (
75 <Hint label="Everyone in the workspace can open it">
76 <span className="flex size-5 items-center justify-center text-faint" aria-label="Everyone in the workspace">
77 <Globe size={13} />
78 </span>
79 </Hint>
80 );
81 }
82 if (folio.general_access === "link") {
83 return (
84 <Hint label="Anyone in the workspace with the link can open it">
85 <span className="flex size-5 items-center justify-center text-faint" aria-label="Anyone in the workspace with the link">
86 <Link2 size={13} />
87 </span>
88 </Hint>
89 );
90 }
91 if (folio.shared_count > 0) {
92 const label = `Shared with ${folio.shared_count} ${folio.shared_count === 1 ? "other" : "others"}${folio.space && folio.inherit ? ` and ${folio.space.name}` : ""}`;
93 return (
94 <Hint label={label}>
95 <span className="flex h-5 items-center gap-0.5 text-[0.6875rem] text-faint tabular-nums" aria-label={label}>
96 <Users size={13} /> {folio.shared_count}
97 </span>
98 </Hint>
99 );
100 }
101 return null;
102}
103
104export function Crumbs({ items }: { items: { label: ReactNode; to?: string }[] }) {
105 return (
106 <nav aria-label="Breadcrumbs" className="flex min-w-0 items-center gap-1 overflow-hidden text-xs text-faint">
107 {items.map((item, i) => (
108 <span key={i} className={`flex items-center gap-1 ${i === items.length - 1 ? "min-w-0" : "shrink-0 max-sm:hidden"}`}>
109 {i > 0 && <ChevronRight size={12} className="shrink-0" aria-hidden="true" />}
110 {item.to ? (
111 <Link to={item.to} prefetch="intent" className="max-w-48 truncate hover:text-fg">
112 {item.label}
113 </Link>
114 ) : (
115 <span className="truncate text-muted">{item.label}</span>
116 )}
117 </span>
118 ))}
119 </nav>
120 );
121}
122
123/** A heading for a section of an Artifacts page. */
124export function SectionTitle({ children, action }: { children: ReactNode; action?: ReactNode }) {
125 return (
126 <div className="mb-3 flex items-center justify-between gap-3">
127 <h2 className="text-sm font-semibold text-fg">{children}</h2>
128 {action}
129 </div>
130 );
131}
132
133export function TemplateCard({ template, action }: { template: FolioTemplate; action: ReactNode }) {
134 return (
135 <Card className="flex flex-col p-4">
136 <span className="flex items-center gap-2">
137 {template.icon ? (
138 <span className="text-2xl leading-none" aria-hidden="true">
139 {template.icon}
140 </span>
141 ) : (
142 <KindIcon kind={template.kind} />
143 )}
144 </span>
145 <span className="mt-3 text-sm font-medium">{template.name}</span>
146 <span className="mt-1 grow text-xs leading-relaxed text-muted">{template.description || (template.builtin ? "" : `Saved by ${template.created_by?.display_name ?? "your workspace"}.`)}</span>
147 <div className="mt-3 flex items-center gap-2">{action}</div>
148 </Card>
149 );
150}