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