File
Blob: src/client/components/ui/page-breadcrumbs.tsx
| 1 | import { Fragment, useMemo } from "react"; |
| 2 | import { Link } from "@tanstack/react-router"; |
| 3 | import { ChevronRight, Lock } from "lucide-react"; |
| 4 | import type { Page, PageAncestor } from "@/shared/types"; |
| 5 | import { DEFAULT_PAGE_TITLE } from "@/shared/constants"; |
| 6 | import { EmojiIcon } from "@/client/components/ui/emoji-icon"; |
| 7 | |
| 8 | interface LabelProps { |
| 9 | currentTitle: string; |
| 10 | currentIcon: string | null; |
| 11 | } |
| 12 | |
| 13 | interface WorkspaceFramedProps extends LabelProps { |
| 14 | workspaceSlug: string; |
| 15 | workspaceName?: string | null; |
| 16 | } |
| 17 | |
| 18 | type 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 | |
| 34 | const RESTRICTED_TITLE = "You don't have access to this page"; |
| 35 | |
| 36 | function Sep() { |
| 37 | return <ChevronRight className="h-3 w-3 shrink-0 text-zinc-500" />; |
| 38 | } |
| 39 | |
| 40 | function 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 | |
| 49 | function 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 | |
| 58 | function EllipsisDot() { |
| 59 | return ( |
| 60 | <span className="select-none text-zinc-500" aria-hidden="true"> |
| 61 | … |
| 62 | </span> |
| 63 | ); |
| 64 | } |
| 65 | |
| 66 | function 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 | |
| 147 | function 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 | |
| 212 | function 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 | |
| 272 | export 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 | } |