this repo has no description
0
fork

Configure Feed

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

Global search command trigger

+376 -219
+2
src/app.jsx
··· 26 26 import MediaModal from './components/media-modal'; 27 27 import Modal from './components/modal'; 28 28 import NotificationService from './components/notification-service'; 29 + import SearchCommand from './components/search-command'; 29 30 import Shortcuts from './components/shortcuts'; 30 31 import ShortcutsSettings from './components/shortcuts-settings'; 31 32 import NotFound from './pages/404'; ··· 449 450 )} 450 451 <NotificationService /> 451 452 <BackgroundService isLoggedIn={isLoggedIn} /> 453 + <SearchCommand onClose={focusDeck} /> 452 454 </> 453 455 ); 454 456 }
+54
src/components/search-command.css
··· 1 + #search-command-container { 2 + position: fixed; 3 + inset: 0; 4 + z-index: 1002; 5 + background-color: var(--backdrop-darker-color); 6 + background-image: radial-gradient( 7 + farthest-corner at top, 8 + var(--backdrop-color), 9 + transparent 10 + ); 11 + display: flex; 12 + justify-content: center; 13 + align-items: flex-start; 14 + padding: 16px; 15 + transition: opacity 0.1s ease-in-out; 16 + } 17 + #search-command-container[hidden] { 18 + opacity: 0; 19 + pointer-events: none; 20 + } 21 + 22 + #search-command-container form { 23 + width: calc(40em - 32px); 24 + max-width: 100%; 25 + transition: transform 0.1s ease-in-out; 26 + } 27 + #search-command-container[hidden] form { 28 + transform: translateY(-64px) scale(0.9); 29 + } 30 + #search-command-container input { 31 + width: 100%; 32 + padding: 16px; 33 + border-radius: 999px; 34 + background-color: var(--bg-faded-color); 35 + border: 2px solid var(--outline-color); 36 + box-shadow: 0 2px 16px var(--drop-shadow-color), 37 + 0 32px 64px var(--drop-shadow-color); 38 + } 39 + #search-command-container input:focus { 40 + outline: 0; 41 + background-color: var(--bg-color); 42 + border-color: var(--link-color); 43 + } 44 + 45 + @media (min-width: 40em) { 46 + #search-command-container { 47 + align-items: center; 48 + background-image: radial-gradient( 49 + closest-side, 50 + var(--backdrop-color), 51 + transparent 52 + ); 53 + } 54 + }
+67
src/components/search-command.jsx
··· 1 + import './search-command.css'; 2 + 3 + import { useRef, useState } from 'preact/hooks'; 4 + import { useHotkeys } from 'react-hotkeys-hook'; 5 + 6 + import SearchForm from './search-form'; 7 + 8 + export default function SearchCommand({ onClose = () => {} }) { 9 + const [showSearch, setShowSearch] = useState(false); 10 + const searchFormRef = useRef(null); 11 + 12 + useHotkeys( 13 + '/', 14 + (e) => { 15 + setShowSearch(true); 16 + setTimeout(() => { 17 + searchFormRef.current?.focus?.(); 18 + }, 0); 19 + }, 20 + { 21 + preventDefault: true, 22 + ignoreEventWhen: (e) => { 23 + const isSearchPage = /\/search/.test(location.hash); 24 + const hasModal = !!document.querySelector('#modal-container > *'); 25 + return isSearchPage || hasModal; 26 + }, 27 + }, 28 + ); 29 + 30 + const closeSearch = () => { 31 + setShowSearch(false); 32 + onClose(); 33 + }; 34 + 35 + useHotkeys( 36 + 'esc', 37 + (e) => { 38 + searchFormRef.current?.blur?.(); 39 + closeSearch(); 40 + }, 41 + { 42 + enabled: showSearch, 43 + enableOnFormTags: true, 44 + preventDefault: true, 45 + }, 46 + ); 47 + 48 + return ( 49 + <div 50 + id="search-command-container" 51 + hidden={!showSearch} 52 + onClick={(e) => { 53 + console.log(e); 54 + if (e.target === e.currentTarget) { 55 + closeSearch(); 56 + } 57 + }} 58 + > 59 + <SearchForm 60 + ref={searchFormRef} 61 + onSubmit={() => { 62 + closeSearch(); 63 + }} 64 + /> 65 + </div> 66 + ); 67 + }
+237
src/components/search-form.jsx
··· 1 + import { forwardRef } from 'preact/compat'; 2 + import { useImperativeHandle, useRef, useState } from 'preact/hooks'; 3 + import { useSearchParams } from 'react-router-dom'; 4 + 5 + import { api } from '../utils/api'; 6 + 7 + import Icon from './icon'; 8 + import Link from './link'; 9 + 10 + const SearchForm = forwardRef((props, ref) => { 11 + const { instance } = api(); 12 + const [searchParams, setSearchParams] = useSearchParams(); 13 + const [searchMenuOpen, setSearchMenuOpen] = useState(false); 14 + const [query, setQuery] = useState(searchParams.get('q') || ''); 15 + const type = searchParams.get('type'); 16 + const formRef = useRef(null); 17 + 18 + const searchFieldRef = useRef(null); 19 + useImperativeHandle(ref, () => ({ 20 + setValue: (value) => { 21 + setQuery(value); 22 + }, 23 + focus: () => { 24 + searchFieldRef.current.focus(); 25 + }, 26 + blur: () => { 27 + searchFieldRef.current.blur(); 28 + }, 29 + })); 30 + 31 + return ( 32 + <form 33 + ref={formRef} 34 + class="search-popover-container" 35 + onSubmit={(e) => { 36 + e.preventDefault(); 37 + 38 + const isSearchPage = /\/search/.test(location.hash); 39 + if (isSearchPage) { 40 + if (query) { 41 + const params = { 42 + q: query, 43 + }; 44 + if (type) params.type = type; // Preserve type 45 + setSearchParams(params); 46 + } else { 47 + setSearchParams({}); 48 + } 49 + } else { 50 + if (query) { 51 + location.hash = `/search?q=${encodeURIComponent(query)}${ 52 + type ? `&type=${type}` : '' 53 + }`; 54 + } else { 55 + location.hash = `/search`; 56 + } 57 + } 58 + 59 + props?.onSubmit?.(e); 60 + }} 61 + > 62 + <input 63 + ref={searchFieldRef} 64 + value={query} 65 + name="q" 66 + type="search" 67 + // autofocus 68 + placeholder="Search" 69 + dir="auto" 70 + onSearch={(e) => { 71 + if (!e.target.value) { 72 + setSearchParams({}); 73 + } 74 + }} 75 + onInput={(e) => { 76 + setQuery(e.target.value); 77 + setSearchMenuOpen(true); 78 + }} 79 + onFocus={() => { 80 + setSearchMenuOpen(true); 81 + }} 82 + onBlur={() => { 83 + setTimeout(() => { 84 + setSearchMenuOpen(false); 85 + }, 100); 86 + formRef.current 87 + ?.querySelector('.search-popover-item.focus') 88 + ?.classList.remove('focus'); 89 + }} 90 + onKeyDown={(e) => { 91 + const { key } = e; 92 + switch (key) { 93 + case 'Escape': 94 + setSearchMenuOpen(false); 95 + break; 96 + case 'Down': 97 + case 'ArrowDown': 98 + e.preventDefault(); 99 + if (searchMenuOpen) { 100 + const focusItem = formRef.current.querySelector( 101 + '.search-popover-item.focus', 102 + ); 103 + if (focusItem) { 104 + let nextItem = focusItem.nextElementSibling; 105 + while (nextItem && nextItem.hidden) { 106 + nextItem = nextItem.nextElementSibling; 107 + } 108 + if (nextItem) { 109 + nextItem.classList.add('focus'); 110 + const siblings = Array.from( 111 + nextItem.parentElement.children, 112 + ).filter((el) => el !== nextItem); 113 + siblings.forEach((el) => { 114 + el.classList.remove('focus'); 115 + }); 116 + } 117 + } else { 118 + const firstItem = formRef.current.querySelector( 119 + '.search-popover-item', 120 + ); 121 + if (firstItem) { 122 + firstItem.classList.add('focus'); 123 + } 124 + } 125 + } 126 + break; 127 + case 'Up': 128 + case 'ArrowUp': 129 + e.preventDefault(); 130 + if (searchMenuOpen) { 131 + const focusItem = document.querySelector( 132 + '.search-popover-item.focus', 133 + ); 134 + if (focusItem) { 135 + let prevItem = focusItem.previousElementSibling; 136 + while (prevItem && prevItem.hidden) { 137 + prevItem = prevItem.previousElementSibling; 138 + } 139 + if (prevItem) { 140 + prevItem.classList.add('focus'); 141 + const siblings = Array.from( 142 + prevItem.parentElement.children, 143 + ).filter((el) => el !== prevItem); 144 + siblings.forEach((el) => { 145 + el.classList.remove('focus'); 146 + }); 147 + } 148 + } else { 149 + const lastItem = document.querySelector( 150 + '.search-popover-item:last-child', 151 + ); 152 + if (lastItem) { 153 + lastItem.classList.add('focus'); 154 + } 155 + } 156 + } 157 + break; 158 + case 'Enter': 159 + if (searchMenuOpen) { 160 + const focusItem = document.querySelector( 161 + '.search-popover-item.focus', 162 + ); 163 + if (focusItem) { 164 + e.preventDefault(); 165 + focusItem.click(); 166 + props?.onSubmit?.(e); 167 + } 168 + setSearchMenuOpen(false); 169 + } 170 + break; 171 + } 172 + }} 173 + /> 174 + <div class="search-popover" hidden={!searchMenuOpen || !query}> 175 + {!!query && 176 + [ 177 + { 178 + label: ( 179 + <> 180 + Posts with <q>{query}</q> 181 + </> 182 + ), 183 + to: `/search?q=${encodeURIComponent(query)}&type=statuses`, 184 + hidden: /^https?:/.test(query), 185 + }, 186 + { 187 + label: ( 188 + <> 189 + Posts tagged with <mark>#{query.replace(/^#/, '')}</mark> 190 + </> 191 + ), 192 + to: `/${instance}/t/${query.replace(/^#/, '')}`, 193 + hidden: 194 + /^@/.test(query) || /^https?:/.test(query) || /\s/.test(query), 195 + top: /^#/.test(query), 196 + type: 'link', 197 + }, 198 + { 199 + label: ( 200 + <> 201 + Look up <mark>{query}</mark> 202 + </> 203 + ), 204 + to: `/${query}`, 205 + hidden: !/^https?:/.test(query), 206 + top: /^https?:/.test(query), 207 + type: 'link', 208 + }, 209 + { 210 + label: ( 211 + <> 212 + Accounts with <q>{query}</q> 213 + </> 214 + ), 215 + to: `/search?q=${encodeURIComponent(query)}&type=accounts`, 216 + }, 217 + ] 218 + .sort((a, b) => { 219 + if (a.top && !b.top) return -1; 220 + if (!a.top && b.top) return 1; 221 + return 0; 222 + }) 223 + .map(({ label, to, hidden, type }) => ( 224 + <Link to={to} class="search-popover-item" hidden={hidden}> 225 + <Icon 226 + icon={type === 'link' ? 'arrow-right' : 'search'} 227 + class="more-insignificant" 228 + /> 229 + <span>{label}</span>{' '} 230 + </Link> 231 + ))} 232 + </div> 233 + </form> 234 + ); 235 + }); 236 + 237 + export default SearchForm;
+1
src/index.css
··· 52 52 --outline-hover-color: rgba(128, 128, 128, 0.7); 53 53 --divider-color: rgba(0, 0, 0, 0.1); 54 54 --backdrop-color: rgba(0, 0, 0, 0.05); 55 + --backdrop-darker-color: rgba(0, 0, 0, 0.25); 55 56 --backdrop-solid-color: #ccc; 56 57 --img-bg-color: rgba(128, 128, 128, 0.2); 57 58 --loader-color: #1c1e2199;
+15 -219
src/pages/search.jsx
··· 1 1 import './search.css'; 2 2 3 - import { forwardRef } from 'preact/compat'; 4 - import { 5 - useEffect, 6 - useImperativeHandle, 7 - useLayoutEffect, 8 - useRef, 9 - useState, 10 - } from 'preact/hooks'; 3 + import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks'; 4 + import { useHotkeys } from 'react-hotkeys-hook'; 11 5 import { InView } from 'react-intersection-observer'; 12 6 import { useParams, useSearchParams } from 'react-router-dom'; 13 7 ··· 16 10 import Link from '../components/link'; 17 11 import Loader from '../components/loader'; 18 12 import NavMenu from '../components/nav-menu'; 13 + import SearchForm from '../components/search-form'; 19 14 import Status from '../components/status'; 20 15 import { api } from '../utils/api'; 21 16 import useTitle from '../utils/useTitle'; ··· 128 123 if (q) { 129 124 searchFormRef.current?.setValue?.(q); 130 125 loadResults(true); 126 + } else { 127 + searchFormRef.current?.focus?.(); 131 128 } 132 - searchFormRef.current?.focus?.(); 133 129 }, [q, type, instance]); 130 + 131 + useHotkeys( 132 + '/', 133 + (e) => { 134 + searchFormRef.current?.focus?.(); 135 + }, 136 + { 137 + preventDefault: true, 138 + }, 139 + ); 134 140 135 141 return ( 136 142 <div id="search-page" class="deck-container" ref={scrollableRef}> ··· 356 362 } 357 363 358 364 export default Search; 359 - 360 - const SearchForm = forwardRef((props, ref) => { 361 - const { instance } = api(); 362 - const [searchParams, setSearchParams] = useSearchParams(); 363 - const [searchMenuOpen, setSearchMenuOpen] = useState(false); 364 - const [query, setQuery] = useState(searchParams.get('q') || ''); 365 - const type = searchParams.get('type'); 366 - const formRef = useRef(null); 367 - 368 - const searchFieldRef = useRef(null); 369 - useImperativeHandle(ref, () => ({ 370 - setValue: (value) => { 371 - setQuery(value); 372 - }, 373 - focus: () => { 374 - searchFieldRef.current.focus(); 375 - }, 376 - })); 377 - 378 - return ( 379 - <form 380 - ref={formRef} 381 - class="search-popover-container" 382 - onSubmit={(e) => { 383 - e.preventDefault(); 384 - 385 - if (query) { 386 - const params = { 387 - q: query, 388 - }; 389 - if (type) params.type = type; // Preserve type 390 - setSearchParams(params); 391 - } else { 392 - setSearchParams({}); 393 - } 394 - }} 395 - > 396 - <input 397 - ref={searchFieldRef} 398 - value={query} 399 - name="q" 400 - type="search" 401 - // autofocus 402 - placeholder="Search" 403 - dir="auto" 404 - onSearch={(e) => { 405 - if (!e.target.value) { 406 - setSearchParams({}); 407 - } 408 - }} 409 - onInput={(e) => { 410 - setQuery(e.target.value); 411 - setSearchMenuOpen(true); 412 - }} 413 - onFocus={() => { 414 - setSearchMenuOpen(true); 415 - }} 416 - onBlur={() => { 417 - setTimeout(() => { 418 - setSearchMenuOpen(false); 419 - }, 100); 420 - formRef.current 421 - ?.querySelector('.search-popover-item.focus') 422 - ?.classList.remove('focus'); 423 - }} 424 - onKeyDown={(e) => { 425 - const { key } = e; 426 - switch (key) { 427 - case 'Escape': 428 - setSearchMenuOpen(false); 429 - break; 430 - case 'Down': 431 - case 'ArrowDown': 432 - e.preventDefault(); 433 - if (searchMenuOpen) { 434 - const focusItem = formRef.current.querySelector( 435 - '.search-popover-item.focus', 436 - ); 437 - if (focusItem) { 438 - let nextItem = focusItem.nextElementSibling; 439 - while (nextItem && nextItem.hidden) { 440 - nextItem = nextItem.nextElementSibling; 441 - } 442 - if (nextItem) { 443 - nextItem.classList.add('focus'); 444 - const siblings = Array.from( 445 - nextItem.parentElement.children, 446 - ).filter((el) => el !== nextItem); 447 - siblings.forEach((el) => { 448 - el.classList.remove('focus'); 449 - }); 450 - } 451 - } else { 452 - const firstItem = formRef.current.querySelector( 453 - '.search-popover-item', 454 - ); 455 - if (firstItem) { 456 - firstItem.classList.add('focus'); 457 - } 458 - } 459 - } 460 - break; 461 - case 'Up': 462 - case 'ArrowUp': 463 - e.preventDefault(); 464 - if (searchMenuOpen) { 465 - const focusItem = document.querySelector( 466 - '.search-popover-item.focus', 467 - ); 468 - if (focusItem) { 469 - let prevItem = focusItem.previousElementSibling; 470 - while (prevItem && prevItem.hidden) { 471 - prevItem = prevItem.previousElementSibling; 472 - } 473 - if (prevItem) { 474 - prevItem.classList.add('focus'); 475 - const siblings = Array.from( 476 - prevItem.parentElement.children, 477 - ).filter((el) => el !== prevItem); 478 - siblings.forEach((el) => { 479 - el.classList.remove('focus'); 480 - }); 481 - } 482 - } else { 483 - const lastItem = document.querySelector( 484 - '.search-popover-item:last-child', 485 - ); 486 - if (lastItem) { 487 - lastItem.classList.add('focus'); 488 - } 489 - } 490 - } 491 - break; 492 - case 'Enter': 493 - if (searchMenuOpen) { 494 - const focusItem = document.querySelector( 495 - '.search-popover-item.focus', 496 - ); 497 - if (focusItem) { 498 - e.preventDefault(); 499 - focusItem.click(); 500 - } 501 - setSearchMenuOpen(false); 502 - } 503 - break; 504 - } 505 - }} 506 - /> 507 - <div class="search-popover" hidden={!searchMenuOpen || !query}> 508 - {!!query && 509 - [ 510 - { 511 - label: ( 512 - <> 513 - Posts with <q>{query}</q> 514 - </> 515 - ), 516 - to: `/search?q=${encodeURIComponent(query)}&type=statuses`, 517 - hidden: /^https?:/.test(query), 518 - }, 519 - { 520 - label: ( 521 - <> 522 - Posts tagged with <mark>#{query.replace(/^#/, '')}</mark> 523 - </> 524 - ), 525 - to: `/${instance}/t/${query.replace(/^#/, '')}`, 526 - hidden: 527 - /^@/.test(query) || /^https?:/.test(query) || /\s/.test(query), 528 - top: /^#/.test(query), 529 - type: 'link', 530 - }, 531 - { 532 - label: ( 533 - <> 534 - Look up <mark>{query}</mark> 535 - </> 536 - ), 537 - to: `/${query}`, 538 - hidden: !/^https?:/.test(query), 539 - top: /^https?:/.test(query), 540 - type: 'link', 541 - }, 542 - { 543 - label: ( 544 - <> 545 - Accounts with <q>{query}</q> 546 - </> 547 - ), 548 - to: `/search?q=${encodeURIComponent(query)}&type=accounts`, 549 - }, 550 - ] 551 - .sort((a, b) => { 552 - if (a.top && !b.top) return -1; 553 - if (!a.top && b.top) return 1; 554 - return 0; 555 - }) 556 - .map(({ label, to, hidden, type }) => ( 557 - <Link to={to} class="search-popover-item" hidden={hidden}> 558 - <Icon 559 - icon={type === 'link' ? 'arrow-right' : 'search'} 560 - class="more-insignificant" 561 - /> 562 - <span>{label}</span>{' '} 563 - </Link> 564 - ))} 565 - </div> 566 - </form> 567 - ); 568 - });