phanpy/src/pages/search.jsx
2023-04-29 20:59:51 +08:00

448 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import './search.css';
import { forwardRef } from 'preact/compat';
import { useEffect, useImperativeHandle, useRef, useState } from 'preact/hooks';
import { useParams, useSearchParams } from 'react-router-dom';
import AccountBlock from '../components/account-block';
import Icon from '../components/icon';
import Link from '../components/link';
import Loader from '../components/loader';
import NavMenu from '../components/nav-menu';
import Status from '../components/status';
import { api } from '../utils/api';
import useTitle from '../utils/useTitle';
function Search(props) {
const params = useParams();
const { masto, instance, authenticated } = api({
instance: params.instance,
});
const [uiState, setUiState] = useState('default');
const [searchParams] = useSearchParams();
const searchFormRef = useRef();
const q = props?.query || searchParams.get('q');
const type = props?.type || searchParams.get('type');
useTitle(
q
? `Search: ${q}${
type
? ` (${
{
statuses: 'Posts',
accounts: 'Accounts',
hashtags: 'Hashtags',
}[type]
})`
: ''
}`
: 'Search',
`/search`,
);
const [statusResults, setStatusResults] = useState([]);
const [accountResults, setAccountResults] = useState([]);
const [hashtagResults, setHashtagResults] = useState([]);
useEffect(() => {
// searchFieldRef.current?.focus?.();
// searchFormRef.current?.focus?.();
if (q) {
// searchFieldRef.current.value = q;
searchFormRef.current?.setValue?.(q);
setUiState('loading');
(async () => {
const results = await masto.v2.search({
q,
limit: type ? 40 : 5,
resolve: authenticated,
type,
});
console.log(results);
setStatusResults(results.statuses);
setAccountResults(results.accounts);
setHashtagResults(results.hashtags);
setUiState('default');
})();
}
}, [q, type, instance]);
return (
<div id="search-page" class="deck-container">
<div class="timeline-deck deck">
<header>
<div class="header-grid">
<div class="header-side">
<NavMenu />
</div>
<SearchForm ref={searchFormRef} />
<div class="header-side">&nbsp;</div>
</div>
</header>
<main>
{!!q && (
<div class="filter-bar">
{!!type && <Link to={`/search${q ? `?q=${q}` : ''}`}> All</Link>}
{[
{
label: 'Accounts',
type: 'accounts',
to: `/search?q=${q}&type=accounts`,
},
{
label: 'Hashtags',
type: 'hashtags',
to: `/search?q=${q}&type=hashtags`,
},
{
label: 'Posts',
type: 'statuses',
to: `/search?q=${q}&type=statuses`,
},
]
.sort((a, b) => {
if (a.type === type) return -1;
if (b.type === type) return 1;
return 0;
})
.map((link) => (
<Link to={link.to}>{link.label}</Link>
))}
</div>
)}
{!!q && uiState !== 'loading' ? (
<>
{(!type || type === 'accounts') && (
<>
{type !== 'accounts' && (
<h2 class="timeline-header">Accounts</h2>
)}
{accountResults.length > 0 ? (
<>
<ul class="timeline flat accounts-list">
{accountResults.map((account) => (
<li>
<AccountBlock
account={account}
instance={instance}
/>
</li>
))}
</ul>
{type !== 'accounts' && (
<div class="ui-state">
<Link
class="plain button"
to={`/search?q=${q}&type=accounts`}
>
See more accounts <Icon icon="arrow-right" />
</Link>
</div>
)}
</>
) : (
<p class="ui-state">No accounts found.</p>
)}
</>
)}
{(!type || type === 'hashtags') && (
<>
{type !== 'hashtags' && (
<h2 class="timeline-header">Hashtags</h2>
)}
{hashtagResults.length > 0 ? (
<>
<ul class="link-list hashtag-list">
{hashtagResults.map((hashtag) => (
<li>
<Link
to={
instance
? `/${instance}/t/${hashtag.name}`
: `/t/${hashtag.name}`
}
>
<Icon icon="hashtag" />
<span>{hashtag.name}</span>
</Link>
</li>
))}
</ul>
{type !== 'hashtags' && (
<div class="ui-state">
<Link
class="plain button"
to={`/search?q=${q}&type=hashtags`}
>
See more hashtags <Icon icon="arrow-right" />
</Link>
</div>
)}
</>
) : (
<p class="ui-state">No hashtags found.</p>
)}
</>
)}
{(!type || type === 'statuses') && (
<>
{type !== 'statuses' && (
<h2 class="timeline-header">Posts</h2>
)}
{statusResults.length > 0 ? (
<>
<ul class="timeline">
{statusResults.map((status) => (
<li>
<Link
class="status-link"
to={
instance
? `/${instance}/s/${status.id}`
: `/s/${status.id}`
}
>
<Status status={status} />
</Link>
</li>
))}
</ul>
{type !== 'statuses' && (
<div class="ui-state">
<Link
class="plain button"
to={`/search?q=${q}&type=statuses`}
>
See more posts <Icon icon="arrow-right" />
</Link>
</div>
)}
</>
) : (
<p class="ui-state">No posts found.</p>
)}
</>
)}
</>
) : uiState === 'loading' ? (
<p class="ui-state">
<Loader abrupt />
</p>
) : (
<p class="ui-state">
Enter your search term or paste a URL above to get started.
</p>
)}
</main>
</div>
</div>
);
}
export default Search;
const SearchForm = forwardRef((props, ref) => {
const { instance } = api();
const [searchParams, setSearchParams] = useSearchParams();
const [searchMenuOpen, setSearchMenuOpen] = useState(false);
const [query, setQuery] = useState(searchParams.q || '');
const formRef = useRef(null);
const searchFieldRef = useRef(null);
useImperativeHandle(ref, () => ({
setValue: (value) => {
setQuery(value);
},
focus: () => {
searchFieldRef.current.focus();
},
}));
return (
<form
ref={formRef}
class="search-popover-container"
onSubmit={(e) => {
e.preventDefault();
if (query) {
setSearchParams({
q: query,
});
} else {
setSearchParams({});
}
}}
>
<input
ref={searchFieldRef}
value={query}
name="q"
type="search"
// autofocus
placeholder="Search"
onSearch={(e) => {
if (!e.target.value) {
setSearchParams({});
}
}}
onInput={(e) => {
setQuery(e.target.value);
setSearchMenuOpen(true);
}}
onFocus={() => {
setSearchMenuOpen(true);
}}
onBlur={() => {
setTimeout(() => {
setSearchMenuOpen(false);
}, 100);
formRef.current
?.querySelector('.search-popover-item.focus')
?.classList.remove('focus');
}}
onKeyDown={(e) => {
const { key } = e;
switch (key) {
case 'Escape':
setSearchMenuOpen(false);
break;
case 'Down':
case 'ArrowDown':
e.preventDefault();
if (searchMenuOpen) {
const focusItem = formRef.current.querySelector(
'.search-popover-item.focus',
);
if (focusItem) {
let nextItem = focusItem.nextElementSibling;
while (nextItem && nextItem.hidden) {
nextItem = nextItem.nextElementSibling;
}
if (nextItem) {
nextItem.classList.add('focus');
const siblings = Array.from(
nextItem.parentElement.children,
).filter((el) => el !== nextItem);
siblings.forEach((el) => {
el.classList.remove('focus');
});
}
} else {
const firstItem = formRef.current.querySelector(
'.search-popover-item',
);
if (firstItem) {
firstItem.classList.add('focus');
}
}
}
break;
case 'Up':
case 'ArrowUp':
e.preventDefault();
if (searchMenuOpen) {
const focusItem = document.querySelector(
'.search-popover-item.focus',
);
if (focusItem) {
let prevItem = focusItem.previousElementSibling;
while (prevItem && prevItem.hidden) {
prevItem = prevItem.previousElementSibling;
}
if (prevItem) {
prevItem.classList.add('focus');
const siblings = Array.from(
prevItem.parentElement.children,
).filter((el) => el !== prevItem);
siblings.forEach((el) => {
el.classList.remove('focus');
});
}
} else {
const lastItem = document.querySelector(
'.search-popover-item:last-child',
);
if (lastItem) {
lastItem.classList.add('focus');
}
}
}
break;
case 'Enter':
if (searchMenuOpen) {
const focusItem = document.querySelector(
'.search-popover-item.focus',
);
if (focusItem) {
e.preventDefault();
focusItem.click();
}
setSearchMenuOpen(false);
}
break;
}
}}
/>
<div class="search-popover" hidden={!searchMenuOpen || !query}>
{!!query &&
[
{
label: (
<>
Posts with <q>{query}</q>
</>
),
to: `/search?q=${encodeURIComponent(query)}&type=statuses`,
hidden: /^https?:/.test(query),
},
{
label: (
<>
Posts tagged with <mark>#{query.replace(/^#/, '')}</mark>
</>
),
to: `/${instance}/t/${query.replace(/^#/, '')}`,
hidden:
/^@/.test(query) || /^https?:/.test(query) || /\s/.test(query),
top: /^#/.test(query),
type: 'link',
},
{
label: (
<>
Look up <mark>{query}</mark>
</>
),
to: `/${query}`,
hidden: !/^https?:/.test(query),
top: /^https?:/.test(query),
type: 'link',
},
{
label: (
<>
Accounts with <q>{query}</q>
</>
),
to: `/search?q=${encodeURIComponent(query)}&type=accounts`,
},
]
.sort((a, b) => {
if (a.top && !b.top) return -1;
if (!a.top && b.top) return 1;
return 0;
})
.map(({ label, to, hidden, type }) => (
<Link to={to} class="search-popover-item" hidden={hidden}>
<Icon
icon={type === 'link' ? 'arrow-right' : 'search'}
class="more-insignificant"
/>
<span>{label}</span>{' '}
</Link>
))}
</div>
</form>
);
});