this repo has no description
0
fork

Configure Feed

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

Filter bar + helper popup for search form

+433 -85
+1 -1
src/components/link.jsx
··· 19 19 let hash = (location.hash || '').replace(/^#/, '').trim(); 20 20 if (hash === '') hash = '/'; 21 21 const { to, ...restProps } = props; 22 - const isActive = hash === to; 22 + const isActive = decodeURIComponent(hash) === to; 23 23 return ( 24 24 <a 25 25 ref={ref}
+58
src/pages/search.css
··· 43 43 background-color: var(--bg-color); 44 44 } 45 45 } 46 + 47 + .search-popover-container { 48 + position: relative; 49 + } 50 + .search-popover { 51 + position: absolute; 52 + left: 8px; 53 + max-width: calc(100% - 16px); 54 + /* right: 8px; */ 55 + background-color: var(--bg-color); 56 + border: 1px solid var(--outline-color); 57 + box-shadow: 0 4px 24px var(--drop-shadow-color); 58 + border-radius: 8px; 59 + display: flex; 60 + flex-direction: column; 61 + animation: appear-smooth 0.2s ease-out; 62 + overflow: hidden; 63 + } 64 + .search-popover[hidden] { 65 + display: none; 66 + } 67 + .search-popover-item { 68 + text-decoration: none; 69 + padding: 8px 16px 8px 8px; 70 + display: flex; 71 + gap: 8px; 72 + align-items: center; 73 + } 74 + .search-popover-item[hidden] { 75 + display: none; 76 + } 77 + .search-popover-item:is(:hover, :focus, .focus) { 78 + background-color: var(--button-bg-color); 79 + color: var(--button-text-color); 80 + } 81 + .search-popover-item :is(mark, q) { 82 + background-color: var(--bg-faded-blur-color); 83 + color: inherit; 84 + } 85 + .search-popover-item:is(:hover, :focus, .focus) :is(mark, q) { 86 + background-color: var(--button-bg-color); 87 + } 88 + .search-popover:hover .search-popover-item.focus:not(:hover, :focus), 89 + .search-popover:hover 90 + .search-popover-item.focus:not(:hover, :focus) 91 + :is(mark, q) { 92 + background-color: unset; 93 + color: unset; 94 + } 95 + .search-popover-item > span { 96 + min-width: 0; 97 + overflow: hidden; 98 + text-overflow: ellipsis; 99 + white-space: nowrap; 100 + } 101 + .search-popover-item:is(:hover, :focus, .focus) > .icon { 102 + opacity: 1; 103 + }
+374 -84
src/pages/search.jsx
··· 1 1 import './search.css'; 2 2 3 - import { useEffect, useRef, useState } from 'preact/hooks'; 3 + import { forwardRef } from 'preact/compat'; 4 + import { useEffect, useImperativeHandle, useRef, useState } from 'preact/hooks'; 4 5 import { useParams, useSearchParams } from 'react-router-dom'; 5 6 6 7 import AccountBlock from '../components/account-block'; ··· 18 19 instance: params.instance, 19 20 }); 20 21 const [uiState, setUiState] = useState('default'); 21 - const [searchParams, setSearchParams] = useSearchParams(); 22 - const searchFieldRef = useRef(); 22 + const [searchParams] = useSearchParams(); 23 + const searchFormRef = useRef(); 23 24 const q = props?.query || searchParams.get('q'); 24 - useTitle(q ? `Search: ${q}` : 'Search', `/search`); 25 + const type = props?.type || searchParams.get('type'); 26 + useTitle( 27 + q 28 + ? `Search: ${q}${ 29 + type 30 + ? ` (${ 31 + { 32 + statuses: 'Posts', 33 + accounts: 'Accounts', 34 + hashtags: 'Hashtags', 35 + }[type] 36 + })` 37 + : '' 38 + }` 39 + : 'Search', 40 + `/search`, 41 + ); 25 42 26 43 const [statusResults, setStatusResults] = useState([]); 27 44 const [accountResults, setAccountResults] = useState([]); 28 45 const [hashtagResults, setHashtagResults] = useState([]); 29 46 useEffect(() => { 30 - searchFieldRef.current?.focus?.(); 47 + // searchFieldRef.current?.focus?.(); 48 + // searchFormRef.current?.focus?.(); 31 49 if (q) { 32 - searchFieldRef.current.value = q; 50 + // searchFieldRef.current.value = q; 51 + searchFormRef.current?.setValue?.(q); 33 52 34 53 setUiState('loading'); 35 54 (async () => { 36 55 const results = await masto.v2.search({ 37 56 q, 38 - limit: 20, 57 + limit: type ? 40 : 5, 39 58 resolve: authenticated, 59 + type, 40 60 }); 41 61 console.log(results); 42 62 setStatusResults(results.statuses); ··· 45 65 setUiState('default'); 46 66 })(); 47 67 } 48 - }, [q, instance]); 68 + }, [q, type, instance]); 49 69 50 70 return ( 51 71 <div id="search-page" class="deck-container"> ··· 55 75 <div class="header-side"> 56 76 <NavMenu /> 57 77 </div> 58 - <form 59 - onSubmit={(e) => { 60 - e.preventDefault(); 61 - const { q } = e.target; 62 - if (q.value) { 63 - setSearchParams({ q: q.value }); 64 - } else { 65 - setSearchParams({}); 66 - } 67 - }} 68 - > 69 - <input 70 - ref={searchFieldRef} 71 - name="q" 72 - type="search" 73 - autofocus 74 - placeholder="Search" 75 - onSearch={(e) => { 76 - if (!e.target.value) { 77 - setSearchParams({}); 78 - } 79 - }} 80 - /> 81 - </form> 82 - <div class="header-side" /> 78 + <SearchForm ref={searchFormRef} /> 79 + <div class="header-side">&nbsp;</div> 83 80 </div> 84 81 </header> 85 82 <main> 83 + {!!q && ( 84 + <div class="filter-bar"> 85 + {!!type && <Link to={`/search${q ? `?q=${q}` : ''}`}>‹ All</Link>} 86 + {[ 87 + { 88 + label: 'Accounts', 89 + type: 'accounts', 90 + to: `/search?q=${q}&type=accounts`, 91 + }, 92 + { 93 + label: 'Hashtags', 94 + type: 'hashtags', 95 + to: `/search?q=${q}&type=hashtags`, 96 + }, 97 + { 98 + label: 'Posts', 99 + type: 'statuses', 100 + to: `/search?q=${q}&type=statuses`, 101 + }, 102 + ] 103 + .sort((a, b) => { 104 + if (a.type === type) return -1; 105 + if (b.type === type) return 1; 106 + return 0; 107 + }) 108 + .map((link) => ( 109 + <Link to={link.to}>{link.label}</Link> 110 + ))} 111 + </div> 112 + )} 86 113 {!!q && uiState !== 'loading' ? ( 87 114 <> 88 - <h2 class="timeline-header">Accounts</h2> 89 - {accountResults.length > 0 ? ( 90 - <ul class="timeline flat accounts-list"> 91 - {accountResults.map((account) => ( 92 - <li> 93 - <AccountBlock account={account} instance={instance} /> 94 - </li> 95 - ))} 96 - </ul> 97 - ) : ( 98 - <p class="ui-state">No accounts found.</p> 115 + {(!type || type === 'accounts') && ( 116 + <> 117 + {type !== 'accounts' && ( 118 + <h2 class="timeline-header">Accounts</h2> 119 + )} 120 + {accountResults.length > 0 ? ( 121 + <> 122 + <ul class="timeline flat accounts-list"> 123 + {accountResults.map((account) => ( 124 + <li> 125 + <AccountBlock 126 + account={account} 127 + instance={instance} 128 + /> 129 + </li> 130 + ))} 131 + </ul> 132 + {type !== 'accounts' && ( 133 + <div class="ui-state"> 134 + <Link 135 + class="plain button" 136 + to={`/search?q=${q}&type=accounts`} 137 + > 138 + See more accounts <Icon icon="arrow-right" /> 139 + </Link> 140 + </div> 141 + )} 142 + </> 143 + ) : ( 144 + <p class="ui-state">No accounts found.</p> 145 + )} 146 + </> 99 147 )} 100 - <h2 class="timeline-header">Hashtags</h2> 101 - {hashtagResults.length > 0 ? ( 102 - <ul class="link-list hashtag-list"> 103 - {hashtagResults.map((hashtag) => ( 104 - <li> 105 - <Link 106 - to={ 107 - instance 108 - ? `/${instance}/t/${hashtag.name}` 109 - : `/t/${hashtag.name}` 110 - } 111 - > 112 - <Icon icon="hashtag" /> 113 - <span>{hashtag.name}</span> 114 - </Link> 115 - </li> 116 - ))} 117 - </ul> 118 - ) : ( 119 - <p class="ui-state">No hashtags found.</p> 148 + {(!type || type === 'hashtags') && ( 149 + <> 150 + {type !== 'hashtags' && ( 151 + <h2 class="timeline-header">Hashtags</h2> 152 + )} 153 + {hashtagResults.length > 0 ? ( 154 + <> 155 + <ul class="link-list hashtag-list"> 156 + {hashtagResults.map((hashtag) => ( 157 + <li> 158 + <Link 159 + to={ 160 + instance 161 + ? `/${instance}/t/${hashtag.name}` 162 + : `/t/${hashtag.name}` 163 + } 164 + > 165 + <Icon icon="hashtag" /> 166 + <span>{hashtag.name}</span> 167 + </Link> 168 + </li> 169 + ))} 170 + </ul> 171 + {type !== 'hashtags' && ( 172 + <div class="ui-state"> 173 + <Link 174 + class="plain button" 175 + to={`/search?q=${q}&type=hashtags`} 176 + > 177 + See more hashtags <Icon icon="arrow-right" /> 178 + </Link> 179 + </div> 180 + )} 181 + </> 182 + ) : ( 183 + <p class="ui-state">No hashtags found.</p> 184 + )} 185 + </> 120 186 )} 121 - <h2 class="timeline-header">Posts</h2> 122 - {statusResults.length > 0 ? ( 123 - <ul class="timeline"> 124 - {statusResults.map((status) => ( 125 - <li> 126 - <Link 127 - class="status-link" 128 - to={ 129 - instance 130 - ? `/${instance}/s/${status.id}` 131 - : `/s/${status.id}` 132 - } 133 - > 134 - <Status status={status} /> 135 - </Link> 136 - </li> 137 - ))} 138 - </ul> 139 - ) : ( 140 - <p class="ui-state">No posts found.</p> 187 + {(!type || type === 'statuses') && ( 188 + <> 189 + {type !== 'statuses' && ( 190 + <h2 class="timeline-header">Posts</h2> 191 + )} 192 + {statusResults.length > 0 ? ( 193 + <> 194 + <ul class="timeline"> 195 + {statusResults.map((status) => ( 196 + <li> 197 + <Link 198 + class="status-link" 199 + to={ 200 + instance 201 + ? `/${instance}/s/${status.id}` 202 + : `/s/${status.id}` 203 + } 204 + > 205 + <Status status={status} /> 206 + </Link> 207 + </li> 208 + ))} 209 + </ul> 210 + {type !== 'statuses' && ( 211 + <div class="ui-state"> 212 + <Link 213 + class="plain button" 214 + to={`/search?q=${q}&type=statuses`} 215 + > 216 + See more posts <Icon icon="arrow-right" /> 217 + </Link> 218 + </div> 219 + )} 220 + </> 221 + ) : ( 222 + <p class="ui-state">No posts found.</p> 223 + )} 224 + </> 141 225 )} 142 226 </> 143 227 ) : uiState === 'loading' ? ( ··· 156 240 } 157 241 158 242 export default Search; 243 + 244 + const SearchForm = forwardRef((props, ref) => { 245 + const { instance } = api(); 246 + const [searchParams, setSearchParams] = useSearchParams(); 247 + const [searchMenuOpen, setSearchMenuOpen] = useState(false); 248 + const [query, setQuery] = useState(searchParams.q || ''); 249 + const formRef = useRef(null); 250 + 251 + const searchFieldRef = useRef(null); 252 + useImperativeHandle(ref, () => ({ 253 + setValue: (value) => { 254 + setQuery(value); 255 + }, 256 + focus: () => { 257 + searchFieldRef.current.focus(); 258 + }, 259 + })); 260 + 261 + return ( 262 + <form 263 + ref={formRef} 264 + class="search-popover-container" 265 + onSubmit={(e) => { 266 + e.preventDefault(); 267 + 268 + if (query) { 269 + setSearchParams({ 270 + q: query, 271 + }); 272 + } else { 273 + setSearchParams({}); 274 + } 275 + }} 276 + > 277 + <input 278 + ref={searchFieldRef} 279 + value={query} 280 + name="q" 281 + type="search" 282 + // autofocus 283 + placeholder="Search" 284 + onSearch={(e) => { 285 + if (!e.target.value) { 286 + setSearchParams({}); 287 + } 288 + }} 289 + onInput={(e) => { 290 + setQuery(e.target.value); 291 + setSearchMenuOpen(true); 292 + }} 293 + onFocus={() => { 294 + setSearchMenuOpen(true); 295 + }} 296 + onBlur={() => { 297 + setTimeout(() => { 298 + setSearchMenuOpen(false); 299 + }, 100); 300 + formRef.current 301 + ?.querySelector('.search-popover-item.focus') 302 + ?.classList.remove('focus'); 303 + }} 304 + onKeyDown={(e) => { 305 + const { key } = e; 306 + switch (key) { 307 + case 'Escape': 308 + setSearchMenuOpen(false); 309 + break; 310 + case 'Down': 311 + case 'ArrowDown': 312 + e.preventDefault(); 313 + if (searchMenuOpen) { 314 + const focusItem = formRef.current.querySelector( 315 + '.search-popover-item.focus', 316 + ); 317 + if (focusItem) { 318 + let nextItem = focusItem.nextElementSibling; 319 + while (nextItem && nextItem.hidden) { 320 + nextItem = nextItem.nextElementSibling; 321 + } 322 + if (nextItem) { 323 + nextItem.classList.add('focus'); 324 + const siblings = Array.from( 325 + nextItem.parentElement.children, 326 + ).filter((el) => el !== nextItem); 327 + siblings.forEach((el) => { 328 + el.classList.remove('focus'); 329 + }); 330 + } 331 + } else { 332 + const firstItem = formRef.current.querySelector( 333 + '.search-popover-item', 334 + ); 335 + if (firstItem) { 336 + firstItem.classList.add('focus'); 337 + } 338 + } 339 + } 340 + break; 341 + case 'Up': 342 + case 'ArrowUp': 343 + e.preventDefault(); 344 + if (searchMenuOpen) { 345 + const focusItem = document.querySelector( 346 + '.search-popover-item.focus', 347 + ); 348 + if (focusItem) { 349 + let prevItem = focusItem.previousElementSibling; 350 + while (prevItem && prevItem.hidden) { 351 + prevItem = prevItem.previousElementSibling; 352 + } 353 + if (prevItem) { 354 + prevItem.classList.add('focus'); 355 + const siblings = Array.from( 356 + prevItem.parentElement.children, 357 + ).filter((el) => el !== prevItem); 358 + siblings.forEach((el) => { 359 + el.classList.remove('focus'); 360 + }); 361 + } 362 + } else { 363 + const lastItem = document.querySelector( 364 + '.search-popover-item:last-child', 365 + ); 366 + if (lastItem) { 367 + lastItem.classList.add('focus'); 368 + } 369 + } 370 + } 371 + break; 372 + case 'Enter': 373 + if (searchMenuOpen) { 374 + const focusItem = document.querySelector( 375 + '.search-popover-item.focus', 376 + ); 377 + if (focusItem) { 378 + e.preventDefault(); 379 + focusItem.click(); 380 + } 381 + setSearchMenuOpen(false); 382 + } 383 + break; 384 + } 385 + }} 386 + /> 387 + <div class="search-popover" hidden={!searchMenuOpen || !query}> 388 + {!!query && 389 + [ 390 + { 391 + label: ( 392 + <> 393 + Posts with <q>{query}</q> 394 + </> 395 + ), 396 + to: `/search?q=${encodeURIComponent(query)}&type=statuses`, 397 + hidden: /^https?:/.test(query), 398 + }, 399 + { 400 + label: ( 401 + <> 402 + Posts tagged with <mark>#{query.replace(/^#/, '')}</mark> 403 + </> 404 + ), 405 + to: `/${instance}/t/${query.replace(/^#/, '')}`, 406 + hidden: 407 + /^@/.test(query) || /^https?:/.test(query) || /\s/.test(query), 408 + top: /^#/.test(query), 409 + type: 'link', 410 + }, 411 + { 412 + label: ( 413 + <> 414 + Look up <mark>{query}</mark> 415 + </> 416 + ), 417 + to: `/${query}`, 418 + hidden: !/^https?:/.test(query), 419 + top: /^https?:/.test(query), 420 + type: 'link', 421 + }, 422 + { 423 + label: ( 424 + <> 425 + Accounts with <q>{query}</q> 426 + </> 427 + ), 428 + to: `/search?q=${encodeURIComponent(query)}&type=accounts`, 429 + }, 430 + ] 431 + .sort((a, b) => { 432 + if (a.top && !b.top) return -1; 433 + if (!a.top && b.top) return 1; 434 + return 0; 435 + }) 436 + .map(({ label, to, hidden, type }) => ( 437 + <Link to={to} class="search-popover-item" hidden={hidden}> 438 + <Icon 439 + icon={type === 'link' ? 'arrow-right' : 'search'} 440 + class="more-insignificant" 441 + /> 442 + <span>{label}</span>{' '} 443 + </Link> 444 + ))} 445 + </div> 446 + </form> 447 + ); 448 + });