Skip to content
File

Blob: src/client/components/ui/page-breadcrumbs.tsx

typescript305 lines
1import { Fragment, useMemo } from "react";
2import { Link } from "@tanstack/react-router";
3import { ChevronRight, Lock } from "lucide-react";
4import type { Page, PageAncestor } from "@/shared/types";
5import { DEFAULT_PAGE_TITLE } from "@/shared/constants";
6import { EmojiIcon } from "@/client/components/ui/emoji-icon";
7 
8interface LabelProps {
9 currentTitle: string;
10 currentIcon: string | null;
11}
12 
13interface WorkspaceFramedProps extends LabelProps {
14 workspaceSlug: string;
15 workspaceName?: string | null;
16}
17 
18type PageBreadcrumbsProps =
19 | (WorkspaceFramedProps & {
20 mode: "workspace";
21 pages: Page[];
22 currentParentId: string | null;
23 })
24 | (WorkspaceFramedProps & {
25 mode: "shared-in-workspace";
26 ancestors: PageAncestor[];
27 })
28 | (LabelProps & {
29 mode: "shared";
30 ancestors: PageAncestor[];
31 onNavigate: (pageId: string) => void;
32 });
33 
34const RESTRICTED_TITLE = "You don't have access to this page";
35 
36function Sep() {
37 return <ChevronRight className="h-3 w-3 shrink-0 text-zinc-500" />;
38}
39 
40function CurrentLabel({ title, icon }: { title: string; icon: string | null }) {
41 return (
42 <span className="inline-flex items-center gap-1 truncate text-zinc-300">
43 {icon && <EmojiIcon emoji={icon} size={12} />}
44 {title || DEFAULT_PAGE_TITLE}
45 </span>
46 );
47}
48 
49function RestrictedLabel() {
50 return (
51 <span className="flex items-center gap-1 text-zinc-500" title={RESTRICTED_TITLE}>
52 <Lock className="h-2.5 w-2.5" />
53 Restricted
54 </span>
55 );
56}
57 
58function EllipsisDot() {
59 return (
60 <span className="select-none text-zinc-500" aria-hidden="true">
61 …
62 </span>
63 );
64}
65 
66function WorkspaceBreadcrumbs({
67 currentTitle,
68 currentIcon,
69 workspaceSlug,
70 workspaceName,
71 pages,
72 currentParentId,
73}: WorkspaceFramedProps & { pages: Page[]; currentParentId: string | null }) {
74 const ancestors = useMemo(() => {
75 const chain: Page[] = [];
76 const byId = new Map(pages.map((p) => [p.id, p]));
77 let cur = currentParentId ? byId.get(currentParentId) : undefined;
78 while (cur && chain.length < 10) {
79 chain.push(cur);
80 cur = cur.parent_id ? byId.get(cur.parent_id) : undefined;
81 }
82 return chain.reverse();
83 }, [pages, currentParentId]);
84 
85 const workspaceLabel = workspaceName ?? workspaceSlug;
86 const immediateAncestor = ancestors.length > 0 ? ancestors[ancestors.length - 1] : null;
87 const hasHiddenPrefix = ancestors.length > 0;
88 
89 return (
90 <nav className="flex min-w-0 items-center text-xs" aria-label="Breadcrumb">
91 <div className="hidden min-w-0 items-center gap-1 md:flex">
92 <Link
93 to="/$workspaceSlug"
94 params={{ workspaceSlug }}
95 className="truncate text-zinc-400 transition-colors hover:text-zinc-300"
96 >
97 {workspaceLabel}
98 </Link>
99 {ancestors.map((a) => (
100 <Fragment key={a.id}>
101 <Sep />
102 <Link
103 to="/$workspaceSlug/$pageId"
104 params={{ workspaceSlug, pageId: a.id }}
105 className="inline-flex items-center gap-1 truncate text-zinc-400 transition-colors hover:text-zinc-300"
106 >
107 {a.icon && <EmojiIcon emoji={a.icon} size={12} />}
108 {a.title || DEFAULT_PAGE_TITLE}
109 </Link>
110 </Fragment>
111 ))}
112 <Sep />
113 <CurrentLabel title={currentTitle} icon={currentIcon} />
114 </div>
115 <div className="flex min-w-0 items-center gap-1 md:hidden">
116 {hasHiddenPrefix && (
117 <>
118 <EllipsisDot />
119 <Sep />
120 </>
121 )}
122 {immediateAncestor ? (
123 <Link
124 to="/$workspaceSlug/$pageId"
125 params={{ workspaceSlug, pageId: immediateAncestor.id }}
126 className="inline-flex min-w-0 items-center gap-1 text-zinc-400 transition-colors hover:text-zinc-300"
127 >
128 {immediateAncestor.icon && <EmojiIcon emoji={immediateAncestor.icon} size={12} />}
129 <span className="truncate">{immediateAncestor.title || DEFAULT_PAGE_TITLE}</span>
130 </Link>
131 ) : (
132 <Link
133 to="/$workspaceSlug"
134 params={{ workspaceSlug }}
135 className="min-w-0 truncate text-zinc-400 transition-colors hover:text-zinc-300"
136 >
137 {workspaceLabel}
138 </Link>
139 )}
140 <Sep />
141 <EllipsisDot />
142 </div>
143 </nav>
144 );
145}
146 
147function SharedInWorkspaceBreadcrumbs({
148 currentTitle,
149 currentIcon,
150 workspaceSlug,
151 workspaceName,
152 ancestors,
153}: WorkspaceFramedProps & { ancestors: PageAncestor[] }) {
154 const workspaceLabel = workspaceName ?? workspaceSlug;
155 const immediateAncestor = ancestors.length > 0 ? ancestors[ancestors.length - 1] : null;
156 const hasHiddenPrefix = ancestors.length > 0;
157 
158 return (
159 <nav className="flex min-w-0 items-center text-xs" aria-label="Breadcrumb">
160 <div className="hidden min-w-0 items-center gap-1 md:flex">
161 <span className="truncate text-zinc-400">{workspaceLabel}</span>
162 {ancestors.map((a) => (
163 <Fragment key={a.id}>
164 <Sep />
165 {a.accessible ? (
166 <Link
167 to="/$workspaceSlug/$pageId"
168 params={{ workspaceSlug, pageId: a.id }}
169 className="inline-flex items-center gap-1 truncate text-zinc-400 transition-colors hover:text-zinc-300"
170 >
171 {a.icon && <EmojiIcon emoji={a.icon} size={12} />}
172 {a.title || DEFAULT_PAGE_TITLE}
173 </Link>
174 ) : (
175 <RestrictedLabel />
176 )}
177 </Fragment>
178 ))}
179 <Sep />
180 <CurrentLabel title={currentTitle} icon={currentIcon} />
181 </div>
182 <div className="flex min-w-0 items-center gap-1 md:hidden">
183 {hasHiddenPrefix && (
184 <>
185 <EllipsisDot />
186 <Sep />
187 </>
188 )}
189 {immediateAncestor ? (
190 immediateAncestor.accessible ? (
191 <Link
192 to="/$workspaceSlug/$pageId"
193 params={{ workspaceSlug, pageId: immediateAncestor.id }}
194 className="inline-flex min-w-0 items-center gap-1 text-zinc-400 transition-colors hover:text-zinc-300"
195 >
196 {immediateAncestor.icon && <EmojiIcon emoji={immediateAncestor.icon} size={12} />}
197 <span className="truncate">{immediateAncestor.title || DEFAULT_PAGE_TITLE}</span>
198 </Link>
199 ) : (
200 <RestrictedLabel />
201 )
202 ) : (
203 <span className="min-w-0 truncate text-zinc-400">{workspaceLabel}</span>
204 )}
205 <Sep />
206 <EllipsisDot />
207 </div>
208 </nav>
209 );
210}
211 
212function SharedBreadcrumbs({
213 currentTitle,
214 currentIcon,
215 ancestors,
216 onNavigate,
217}: LabelProps & {
218 ancestors: PageAncestor[];
219 onNavigate: (pageId: string) => void;
220}) {
221 if (ancestors.length === 0) return null;
222 
223 const immediateAncestor = ancestors[ancestors.length - 1];
224 const hasHiddenPrefix = ancestors.length > 1;
225 
226 return (
227 <nav className="flex min-w-0 items-center text-xs" aria-label="Breadcrumb">
228 <div className="hidden min-w-0 items-center gap-1 md:flex">
229 {ancestors.map((a) => (
230 <Fragment key={a.id}>
231 {a.accessible ? (
232 <button
233 onClick={() => onNavigate(a.id)}
234 className="inline-flex items-center gap-1 truncate text-zinc-500 transition-colors hover:text-zinc-300"
235 >
236 {a.icon && <EmojiIcon emoji={a.icon} size={12} />}
237 {a.title || DEFAULT_PAGE_TITLE}
238 </button>
239 ) : (
240 <RestrictedLabel />
241 )}
242 <Sep />
243 </Fragment>
244 ))}
245 <CurrentLabel title={currentTitle} icon={currentIcon} />
246 </div>
247 <div className="flex min-w-0 items-center gap-1 md:hidden">
248 {hasHiddenPrefix && (
249 <>
250 <EllipsisDot />
251 <Sep />
252 </>
253 )}
254 {immediateAncestor.accessible ? (
255 <button
256 onClick={() => onNavigate(immediateAncestor.id)}
257 className="inline-flex min-w-0 items-center gap-1 text-zinc-500 transition-colors hover:text-zinc-300"
258 >
259 {immediateAncestor.icon && <EmojiIcon emoji={immediateAncestor.icon} size={12} />}
260 <span className="truncate">{immediateAncestor.title || DEFAULT_PAGE_TITLE}</span>
261 </button>
262 ) : (
263 <RestrictedLabel />
264 )}
265 <Sep />
266 <EllipsisDot />
267 </div>
268 </nav>
269 );
270}
271 
272export function PageBreadcrumbs(props: PageBreadcrumbsProps) {
273 if (props.mode === "workspace") {
274 return (
275 <WorkspaceBreadcrumbs
276 currentTitle={props.currentTitle}
277 currentIcon={props.currentIcon}
278 workspaceSlug={props.workspaceSlug}
279 workspaceName={props.workspaceName}
280 pages={props.pages}
281 currentParentId={props.currentParentId}
282 />
283 );
284 }
285 if (props.mode === "shared-in-workspace") {
286 return (
287 <SharedInWorkspaceBreadcrumbs
288 currentTitle={props.currentTitle}
289 currentIcon={props.currentIcon}
290 workspaceSlug={props.workspaceSlug}
291 workspaceName={props.workspaceName}
292 ancestors={props.ancestors}
293 />
294 );
295 }
296 return (
297 <SharedBreadcrumbs
298 currentTitle={props.currentTitle}
299 currentIcon={props.currentIcon}
300 ancestors={props.ancestors}
301 onNavigate={props.onNavigate}
302 />
303 );
304}