cedarstalking with keyboard shortcuts
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at 2e1496913191a9d1b76e87d1ffe7cc9d36e2cc4b 804 lines 25 kB view raw
1import { 2 Action, 3 ActionPanel, 4 Color, 5 Detail, 6 Icon, 7 Image, 8 List, 9 showToast, 10 Toast, 11} from "@raycast/api"; 12import { readFile } from "fs/promises"; 13import { useEffect, useRef, useState } from "react"; 14import { 15 AuthRequiredError, 16 type Department, 17 type DirectoryPerson, 18 type PersonInfo, 19 type Population, 20 getDepartments, 21 getPersonInfo, 22 getPersonTerms, 23 getPopulations, 24 searchDirectory, 25} from "./api"; 26import { 27 clearCookie, 28 getStoredCookie, 29 launchAuthBrowser, 30 storeCookie, 31} from "./auth"; 32import { getCacheSize, mergePeopleIntoCache, searchCache } from "./cache"; 33import { getCachedPhotoPath } from "./images"; 34 35type AuthState = 36 | { kind: "loading" } 37 | { kind: "ready"; cookie: string } 38 | { kind: "sign-in"; signInUrl: string } 39 | { kind: "signing-in" }; 40 41const CLASS_LABELS: Record<string, string> = { 42 FR: "Freshman", 43 SO: "Sophomore", 44 JR: "Junior", 45 SR: "Senior", 46 GR: "Graduate", 47 GS: "Graduate Student", 48 HS: "High School", 49 P1: "Pharmacy Year 1", 50 P2: "Pharmacy Year 2", 51 P3: "Pharmacy Year 3", 52 P4: "Pharmacy Year 4", 53}; 54 55const TYPE_LABELS: Record<string, string> = { 56 UG: "Undergraduate", 57 GR: "Graduate", 58 GS: "Graduate Student", 59 DE: "Dual Enrollment", 60 P1: "Pharmacy Year 1", 61 P2: "Pharmacy Year 2", 62 P3: "Pharmacy Year 3", 63 P4: "Pharmacy Year 4", 64}; 65 66function displayName(person: DirectoryPerson, showLegal = false): string { 67 const nickname = 68 person.Nickname && person.Nickname !== person.FirstName 69 ? person.Nickname 70 : null; 71 const first = 72 showLegal && nickname 73 ? `${nickname} (${person.FirstName})` 74 : (nickname ?? person.FirstName); 75 const middle = person.MiddleName ? ` ${person.MiddleName}` : ""; 76 return `${first}${middle} ${person.LastName}`; 77} 78 79function email(username: string): string { 80 return `${username}@cedarville.edu`; 81} 82 83function formatPhone(phone: string): string { 84 // 4-digit campus extensions → "ext. XXXX" 85 return /^\d{4}$/.test(phone.trim()) ? `ext. ${phone.trim()}` : phone; 86} 87 88function parseSearchQuery(query: string): { 89 firstName: string; 90 lastName: string; 91} { 92 const parts = query.trim().split(/\s+/); 93 if (parts.length === 1) return { firstName: parts[0], lastName: "" }; 94 const lastName = parts.pop() ?? ""; 95 return { firstName: parts.join(" "), lastName }; 96} 97 98// ─── Person detail view ──────────────────────────────────────────────────── 99 100function formatTime(t: string): string { 101 const [h, m] = t.split(":"); 102 const hour = parseInt(h, 10); 103 return `${hour > 12 ? hour - 12 : hour || 12}:${m} ${hour >= 12 ? "PM" : "AM"}`; 104} 105 106function PersonDetail({ 107 person, 108 photoPath, 109 cookie, 110 onSignOut, 111}: { 112 person: DirectoryPerson; 113 photoPath: string | null; 114 cookie: string; 115 onSignOut: () => void; 116}) { 117 const name = displayName(person, true); 118 const [photoDataUrl, setPhotoDataUrl] = useState<string | null>(null); 119 const [info, setInfo] = useState<PersonInfo | null>(null); 120 121 useEffect(() => { 122 if (!photoPath) return; 123 readFile(photoPath) 124 .then((buf) => 125 setPhotoDataUrl(`data:image/jpeg;base64,${buf.toString("base64")}`), 126 ) 127 .catch(() => {}); 128 }, [photoPath]); 129 130 useEffect(() => { 131 (async () => { 132 const terms = await getPersonTerms(person.Id, cookie); 133 if (!terms.length) return; 134 const now = Date.now(); 135 const current = 136 terms.find((t) => { 137 const start = t.start ? new Date(t.start).getTime() : 0; 138 const end = t.end ? new Date(t.end).getTime() : Infinity; 139 return now >= start && now <= end; 140 }) ?? terms[0]; 141 const result = await getPersonInfo(person.Id, current.code, cookie); 142 if (result) setInfo(result); 143 })(); 144 }, [person.Id, cookie]); 145 146 // Photo full-width at top, name + italic tags below 147 const md: string[] = []; 148 if (photoDataUrl) md.push(`![Photo](${photoDataUrl})`); 149 md.push(`# ${name}`); 150 const isStaff = !person.StudentType || !!(person.Title?.trim() && person.OfficeBuildingCode); 151 const tags: string[] = []; 152 if (isStaff) { 153 tags.push("Staff"); 154 } else if (person.StudentType === "DE") { 155 tags.push("Dual Enrollment"); 156 } else { 157 if ( 158 person.StudentClass && 159 CLASS_LABELS[person.StudentClass] && 160 person.StudentClass !== "GS" && 161 person.StudentClass !== "HS" && 162 person.StudentType !== null 163 ) 164 tags.push(CLASS_LABELS[person.StudentClass]); 165 if (person.StudentType) 166 tags.push(TYPE_LABELS[person.StudentType] ?? person.StudentType); 167 } 168 if (person.studentWorker) tags.push("Student Worker"); 169 if (person.Title?.trim()) tags.push(person.Title.trim()); 170 if (tags.length) md.push(`*${tags.join(" · ")}*`); 171 172 const scheduleItems = info?.faculty?.isFaculty 173 ? info.faculty.scheduleItems 174 : info?.student?.isStudent 175 ? info.student.scheduleItems 176 : []; 177 const termDesc = info?.faculty?.isFaculty 178 ? info.faculty.term?.description 179 : info?.student?.term?.description; 180 181 if (scheduleItems.length) { 182 md.push(`## Schedule${termDesc ? `${termDesc}` : ""}`); 183 for (const item of scheduleItems) { 184 md.push( 185 `**${item.title}** — ${item.description} \n${item.day} ${formatTime(item.startTime)}${formatTime(item.endTime)}`, 186 ); 187 } 188 } 189 190 return ( 191 <Detail 192 isLoading={!info} 193 markdown={md.join("\n\n")} 194 navigationTitle={name} 195 metadata={ 196 <Detail.Metadata> 197 {person.Username && ( 198 <Detail.Metadata.Label 199 title="Email" 200 text={email(person.Username)} 201 /> 202 )} 203 {person.DepartmentDescription && ( 204 <Detail.Metadata.Label 205 title="Department" 206 text={person.DepartmentDescription} 207 /> 208 )} 209 {(isStaff || 210 person.StudentClass || 211 person.studentWorker) && <Detail.Metadata.Separator />} 212 {isStaff ? ( 213 <Detail.Metadata.TagList title="Role"> 214 <Detail.Metadata.TagList.Item text="Staff" color={Color.Green} /> 215 </Detail.Metadata.TagList> 216 ) : person.StudentType === "DE" ? ( 217 <Detail.Metadata.TagList title="Program"> 218 <Detail.Metadata.TagList.Item 219 text="Dual Enrollment" 220 color={Color.Orange} 221 /> 222 </Detail.Metadata.TagList> 223 ) : ( 224 <> 225 {person.StudentClass && 226 CLASS_LABELS[person.StudentClass] && 227 person.StudentClass !== "GS" && 228 person.StudentClass !== "HS" && 229 person.StudentType !== null && ( 230 <Detail.Metadata.TagList title="Year"> 231 <Detail.Metadata.TagList.Item 232 text={CLASS_LABELS[person.StudentClass]} 233 color={Color.Blue} 234 /> 235 </Detail.Metadata.TagList> 236 )} 237 {person.StudentType && ( 238 <Detail.Metadata.Label 239 title="Program" 240 text={TYPE_LABELS[person.StudentType] ?? person.StudentType} 241 /> 242 )} 243 </> 244 )} 245 {person.studentWorker && ( 246 <Detail.Metadata.TagList title="Role"> 247 <Detail.Metadata.TagList.Item 248 text="Student Worker" 249 color={Color.Yellow} 250 /> 251 </Detail.Metadata.TagList> 252 )} 253 {(person.DormName || 254 person.OfficeBuildingName || 255 person.OfficePhone) && <Detail.Metadata.Separator />} 256 {person.DormName && ( 257 <Detail.Metadata.Label 258 title="Dorm" 259 text={ 260 person.DormRoom 261 ? `${person.DormName}, Room ${person.DormRoom}` 262 : person.DormName 263 } 264 /> 265 )} 266 {person.OfficeBuildingName && ( 267 <Detail.Metadata.Label 268 title="Office" 269 text={ 270 person.OfficeRoom 271 ? `${person.OfficeBuildingName}, Room ${person.OfficeRoom}` 272 : person.OfficeBuildingName 273 } 274 /> 275 )} 276 {person.OfficePhone && ( 277 <Detail.Metadata.Label 278 title="Phone" 279 text={formatPhone(person.OfficePhone)} 280 /> 281 )} 282 {(person.AddressCity || person.AddressState) && ( 283 <Detail.Metadata.Separator /> 284 )} 285 {(person.AddressCity || person.AddressState) && ( 286 <Detail.Metadata.Label 287 title="Hometown" 288 text={[person.AddressCity, person.AddressState] 289 .filter(Boolean) 290 .join(", ")} 291 /> 292 )} 293 {info?.student?.isStudent && info.student.majors.length > 0 && ( 294 <> 295 <Detail.Metadata.Separator /> 296 {info.student.majors.map((m) => ( 297 <Detail.Metadata.Label 298 key={m.code} 299 title="Major" 300 text={m.description} 301 /> 302 ))} 303 {info.student.minors.map((m) => ( 304 <Detail.Metadata.Label 305 key={m.code} 306 title="Minor" 307 text={m.description} 308 /> 309 ))} 310 {info.student.concentrations.map((c) => ( 311 <Detail.Metadata.Label 312 key={c.code} 313 title="Concentration" 314 text={c.description} 315 /> 316 ))} 317 {info.student.advisors.map((a) => ( 318 <Detail.Metadata.Label 319 key={a.id} 320 title="Advisor" 321 text={a.name} 322 /> 323 ))} 324 </> 325 )} 326 {info?.faculty?.isFaculty && info.faculty.facultyDepts.length > 0 && ( 327 <> 328 <Detail.Metadata.Separator /> 329 {info.faculty.facultyDepts.map((d) => ( 330 <Detail.Metadata.Label 331 key={d.code} 332 title="Faculty Dept" 333 text={d.description} 334 /> 335 ))} 336 </> 337 )} 338 <Detail.Metadata.Separator /> 339 <Detail.Metadata.Label title="ID" text={person.Id} /> 340 </Detail.Metadata> 341 } 342 actions={ 343 <ActionPanel> 344 {person.Username && ( 345 <Action.CopyToClipboard 346 title="Copy Email" 347 content={email(person.Username)} 348 /> 349 )} 350 {person.OfficePhone && ( 351 <Action.CopyToClipboard 352 title="Copy Phone" 353 content={formatPhone(person.OfficePhone)} 354 /> 355 )} 356 <Action.CopyToClipboard 357 title="Copy ID" 358 content={person.Id} 359 icon={Icon.Person} 360 /> 361 <Action.OpenInBrowser 362 title="Open Info Page" 363 url={`https://selfservice.cedarville.edu/Cedarinfo/Info?id=${person.Id}`} 364 icon={Icon.Globe} 365 /> 366 <Action.CopyToClipboard 367 title="Export as JSON" 368 icon={Icon.Code} 369 content={JSON.stringify(person, null, 2)} 370 shortcut={{ modifiers: ["cmd", "shift"], key: "j" }} 371 /> 372 <Action 373 title="Sign Out" 374 icon={Icon.ArrowLeft} 375 onAction={onSignOut} 376 shortcut={{ modifiers: ["cmd", "shift"], key: "s" }} 377 /> 378 </ActionPanel> 379 } 380 /> 381 ); 382} 383 384// ─── Person list item ────────────────────────────────────────────────────── 385 386function PersonListItem({ 387 person, 388 photoPath, 389 cookie, 390 onSignOut, 391}: { 392 person: DirectoryPerson; 393 photoPath: string | null; 394 cookie: string; 395 onSignOut: () => void; 396}) { 397 const name = displayName(person); 398 399 // Subtitle: title for staff, dorm for students 400 const isStudent = 401 !!person.StudentClass && 402 !!person.StudentType && 403 !(person.Title?.trim() && person.OfficeBuildingCode); 404 const hasOffice = !isStudent && !!person.OfficeBuildingCode; 405 const rawTitle = isStudent 406 ? person.DormName 407 ? `${person.DormName}${person.DormRoom ? ` ${person.DormRoom}` : ""}` 408 : person.Username 409 ? email(person.Username) 410 : undefined 411 : ((person.Title?.trim() || 412 (person.Username ? email(person.Username) : undefined)) ?? 413 undefined); 414 const subtitle = 415 hasOffice && rawTitle && rawTitle.length > 30 416 ? `${rawTitle.slice(0, 29)}` 417 : rawTitle; 418 419 // Badge 420 let badge: List.Item.Accessory | null = null; 421 if (!isStudent) { 422 badge = { tag: { value: "Staff", color: Color.Green } }; 423 } else if (person.StudentType === "DE") { 424 badge = { tag: { value: "DE", color: Color.Orange } }; 425 } else if (person.StudentType === "GS" || person.StudentClass === "GS") { 426 badge = { tag: { value: "Graduate", color: Color.Purple } }; 427 } else if ( 428 person.StudentClass && 429 CLASS_LABELS[person.StudentClass] && 430 person.StudentClass !== "HS" && 431 person.StudentType !== null 432 ) { 433 badge = { 434 tag: { value: CLASS_LABELS[person.StudentClass], color: Color.Blue }, 435 }; 436 } 437 438 // Accessories: office then badge 439 const accessories: List.Item.Accessory[] = []; 440 if (hasOffice) { 441 const officeLabel = person.OfficeRoom 442 ? `${person.OfficeBuildingCode} ${person.OfficeRoom}` 443 : person.OfficeBuildingCode!; 444 accessories.push({ text: officeLabel, icon: Icon.Building }); 445 } 446 if (badge) accessories.push(badge); 447 448 return ( 449 <List.Item 450 title={name} 451 subtitle={subtitle} 452 icon={ 453 photoPath 454 ? { 455 source: photoPath, 456 mask: Image.Mask.Circle, 457 fallback: Icon.Person, 458 } 459 : Icon.Person 460 } 461 accessories={accessories} 462 actions={ 463 <ActionPanel> 464 <Action.Push 465 title="View Details" 466 icon={Icon.Eye} 467 target={ 468 <PersonDetail 469 person={person} 470 photoPath={photoPath} 471 cookie={cookie} 472 onSignOut={onSignOut} 473 /> 474 } 475 /> 476 {person.Username && ( 477 <Action.CopyToClipboard 478 title="Copy Email" 479 content={email(person.Username)} 480 /> 481 )} 482 {person.OfficePhone && ( 483 <Action.CopyToClipboard 484 title="Copy Phone" 485 content={formatPhone(person.OfficePhone)} 486 /> 487 )} 488 <Action.CopyToClipboard 489 title="Copy ID" 490 content={person.Id} 491 icon={Icon.Person} 492 /> 493 <Action.OpenInBrowser 494 title="Open Info Page" 495 url={`https://selfservice.cedarville.edu/Cedarinfo/Info?id=${person.Id}`} 496 icon={Icon.Globe} 497 /> 498 <Action.CopyToClipboard 499 title="Export as JSON" 500 icon={Icon.Code} 501 content={JSON.stringify(person, null, 2)} 502 shortcut={{ modifiers: ["cmd", "shift"], key: "j" }} 503 /> 504 <Action 505 title="Sign Out" 506 icon={Icon.ArrowLeft} 507 onAction={onSignOut} 508 shortcut={{ modifiers: ["cmd", "shift"], key: "s" }} 509 /> 510 </ActionPanel> 511 } 512 /> 513 ); 514} 515 516// ─── Main command ────────────────────────────────────────────────────────── 517 518export default function SearchDirectory() { 519 const [authState, setAuthState] = useState<AuthState>({ kind: "loading" }); 520 const [query, setQuery] = useState(""); 521 const [filter, setFilter] = useState(""); 522 const [results, setResults] = useState<DirectoryPerson[]>([]); 523 const [photoPaths, setPhotoPaths] = useState<Record<string, string>>({}); 524 const [cacheSize, setCacheSize] = useState(0); 525 const [isSearching, setIsSearching] = useState(false); 526 const [departments, setDepartments] = useState<Department[]>([]); 527 const [populations, setPopulations] = useState<Population[]>([]); 528 const searchRef = useRef<AbortController | null>(null); 529 530 useEffect(() => { 531 (async () => { 532 const cookie = await getStoredCookie(); 533 if (cookie) { 534 setAuthState({ kind: "ready", cookie }); 535 setCacheSize(await getCacheSize()); 536 getDepartments(cookie).then(setDepartments); 537 getPopulations(cookie).then(setPopulations); 538 return; 539 } 540 try { 541 await searchDirectory("probe", "probe"); 542 setAuthState({ 543 kind: "sign-in", 544 signInUrl: "https://selfservice.cedarville.edu/cedarinfo/directory", 545 }); 546 } catch (err) { 547 setAuthState({ 548 kind: "sign-in", 549 signInUrl: 550 err instanceof AuthRequiredError 551 ? err.signInUrl 552 : "https://selfservice.cedarville.edu/cedarinfo/directory", 553 }); 554 } 555 })(); 556 }, []); 557 558 useEffect(() => { 559 if (authState.kind !== "ready") return; 560 561 searchRef.current?.abort(); 562 const controller = new AbortController(); 563 searchRef.current = controller; 564 565 // Parse filter value into API options 566 const apiOptions = filter.startsWith("dept:") 567 ? { department: filter.slice(5) } 568 : filter.startsWith("pop:") 569 ? { population: Number(filter.slice(4)) } 570 : {}; 571 const hasFilter = !!filter; 572 573 // Cache search runs immediately (no debounce) 574 if (!hasFilter) { 575 searchCache(query.trim()).then((local) => { 576 if (!controller.signal.aborted) setResults(local); 577 }); 578 } 579 580 const run = async () => { 581 const trimmed = query.trim(); 582 583 if (!trimmed) return; 584 585 setIsSearching(true); 586 try { 587 const { firstName, lastName } = parseSearchQuery(trimmed); 588 589 // For single-word queries, search as both first and last name in parallel 590 let fresh: DirectoryPerson[]; 591 if (trimmed && !lastName) { 592 const [byFirst, byLast] = await Promise.all([ 593 searchDirectory(firstName, "", authState.cookie, apiOptions), 594 searchDirectory("", firstName, authState.cookie, apiOptions), 595 ]); 596 const seen = new Set<string>(); 597 fresh = []; 598 for (const p of [...byFirst, ...byLast]) { 599 if (!seen.has(p.Id)) { 600 seen.add(p.Id); 601 fresh.push(p); 602 } 603 } 604 } else { 605 fresh = await searchDirectory( 606 firstName, 607 lastName, 608 authState.cookie, 609 apiOptions, 610 ); 611 } 612 613 if (!controller.signal.aborted) { 614 await mergePeopleIntoCache(fresh); 615 setCacheSize(await getCacheSize()); 616 617 if (hasFilter) { 618 setResults(fresh); 619 } else { 620 // Re-run cache search after merge so order stays stable (fuzzy score) 621 setResults(await searchCache(trimmed)); 622 } 623 } 624 } catch (err) { 625 if (controller.signal.aborted) return; 626 if (err instanceof AuthRequiredError) { 627 await clearCookie(); 628 setAuthState({ kind: "sign-in", signInUrl: err.signInUrl }); 629 } else { 630 await showToast({ 631 style: Toast.Style.Failure, 632 title: "Search failed", 633 message: String(err), 634 }); 635 } 636 } finally { 637 if (!controller.signal.aborted) setIsSearching(false); 638 } 639 }; 640 641 const timer = setTimeout(run, 300); 642 return () => { 643 clearTimeout(timer); 644 controller.abort(); 645 }; 646 }, [query, filter, authState]); 647 648 useEffect(() => { 649 if (authState.kind !== "ready") return; 650 const { cookie } = authState; 651 for (const person of results) { 652 if (!person.PhotoUrl || photoPaths[person.Id]) continue; 653 getCachedPhotoPath(person.Id, person.PhotoUrl, cookie).then((p) => { 654 if (p) setPhotoPaths((prev) => ({ ...prev, [person.Id]: p })); 655 }); 656 } 657 }, [results, authState]); 658 659 async function handleSignOut() { 660 await clearCookie(); 661 setResults([]); 662 try { 663 await searchDirectory("probe", "probe"); 664 setAuthState({ 665 kind: "sign-in", 666 signInUrl: "https://selfservice.cedarville.edu/cedarinfo/directory", 667 }); 668 } catch (err) { 669 setAuthState({ 670 kind: "sign-in", 671 signInUrl: 672 err instanceof AuthRequiredError 673 ? err.signInUrl 674 : "https://selfservice.cedarville.edu/cedarinfo/directory", 675 }); 676 } 677 } 678 679 async function handleSignIn(signInUrl: string) { 680 setAuthState({ kind: "signing-in" }); 681 const toast = await showToast({ 682 style: Toast.Style.Animated, 683 title: "Opening sign-in window…", 684 message: "Complete login in the window that opens", 685 }); 686 try { 687 const cookie = await launchAuthBrowser(signInUrl); 688 await storeCookie(cookie); 689 toast.style = Toast.Style.Success; 690 toast.title = "Signed in!"; 691 setCacheSize(await getCacheSize()); 692 getDepartments(cookie).then(setDepartments); 693 getPopulations(cookie).then(setPopulations); 694 setAuthState({ kind: "ready", cookie }); 695 } catch (err) { 696 toast.style = Toast.Style.Failure; 697 toast.title = String(err); 698 setAuthState({ kind: "sign-in", signInUrl }); 699 } 700 } 701 702 // ── Auth screens ──────────────────────────────────────────────────────── 703 704 if (authState.kind === "loading") return <List isLoading />; 705 706 if (authState.kind === "sign-in") { 707 return ( 708 <List> 709 <List.EmptyView 710 title="Sign in to Cedarville" 711 description="A small sign-in window will open. No browser or cookies are touched." 712 icon={Icon.Lock} 713 actions={ 714 <ActionPanel> 715 <Action 716 title="Sign In" 717 icon={Icon.Person} 718 onAction={() => handleSignIn(authState.signInUrl)} 719 /> 720 </ActionPanel> 721 } 722 /> 723 </List> 724 ); 725 } 726 727 if (authState.kind === "signing-in") { 728 return ( 729 <List isLoading> 730 <List.EmptyView 731 title="Waiting for sign-in…" 732 description="Complete login in the window that just opened." 733 icon={Icon.Clock} 734 /> 735 </List> 736 ); 737 } 738 739 // ── Ready: search ─────────────────────────────────────────────────────── 740 741 return ( 742 <List 743 isLoading={isSearching} 744 searchBarPlaceholder="Search by name…" 745 onSearchTextChange={setQuery} 746 throttle={false} 747 searchBarAccessory={ 748 <List.Dropdown tooltip="Filter" value={filter} onChange={setFilter}> 749 <List.Dropdown.Item title="All People" value="" /> 750 {populations.length > 0 && ( 751 <List.Dropdown.Section title="By Type"> 752 {populations.map((p) => ( 753 <List.Dropdown.Item 754 key={p.code} 755 title={p.desc} 756 value={`pop:${p.code}`} 757 /> 758 ))} 759 </List.Dropdown.Section> 760 )} 761 {departments.length > 0 && ( 762 <List.Dropdown.Section title="By Department"> 763 {departments.map((d) => ( 764 <List.Dropdown.Item 765 key={d.code} 766 title={d.description} 767 value={`dept:${d.code}`} 768 /> 769 ))} 770 </List.Dropdown.Section> 771 )} 772 </List.Dropdown> 773 } 774 > 775 {results.length === 0 ? ( 776 <List.EmptyView 777 title={ 778 query.trim() 779 ? "No results found" 780 : "Search the Cedarville Directory" 781 } 782 description={ 783 query.trim() 784 ? `No one matched "${query}"` 785 : cacheSize > 0 786 ? `${cacheSize} people cached` 787 : "Start typing to search" 788 } 789 icon={query.trim() ? Icon.MagnifyingGlass : Icon.Person} 790 /> 791 ) : ( 792 results.map((person) => ( 793 <PersonListItem 794 key={person.Id} 795 person={person} 796 photoPath={photoPaths[person.Id] ?? null} 797 cookie={authState.cookie} 798 onSignOut={handleSignOut} 799 /> 800 )) 801 )} 802 </List> 803 ); 804}