diff --git a/CHANGELOG.md b/CHANGELOG.md index 92dbb4aa1..962f71847 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [1.136.0] - Not released +### Changed +- The advanced search form is refactored to support the new search operators. +### Added +- When user presses '/' or 'Ctrl+K' hotkeys, the search input form in the + header is focused. +- This form now has an "Advanced search options" button that redirects to the + advanced search form. ### Fixed - The post attachments now appear in the same order as they were added by user. Previously, attachments were sorted by the upload order. diff --git a/src/components/advanced-search-form/advanced-search-form.jsx b/src/components/advanced-search-form/advanced-search-form.jsx new file mode 100644 index 000000000..19c55579a --- /dev/null +++ b/src/components/advanced-search-form/advanced-search-form.jsx @@ -0,0 +1,292 @@ +import cn from 'classnames'; +import { browserHistory } from 'react-router'; +import { useEffect, useMemo, useReducer, useState } from 'react'; +import { useEvent } from 'react-use-event-hook'; +import { faChevronDown } from '@fortawesome/free-solid-svg-icons'; +import { useMediaQuery } from '../hooks/media-query'; +import { ButtonLink } from '../button-link'; +import { Icon } from '../fontawesome-icons'; +import { useSearchQuery } from '../hooks/search-query'; +import style from './advanced-search-form.module.scss'; +import { BoolInput } from './bool-input'; +import { ChooseInput } from './choose-input'; +import { Columns } from './columns'; +import { IntervalInput } from './interval-input'; +import { Section } from './section'; +import { TextInput } from './text-input'; +import { + intervalFilters, + parseQuery, + useCheckboxField, + usernameFilters, + usernames, + useTextField, +} from './helpers'; +import { reducer } from './reducer'; +import { filtersContext } from './context'; + +export function AdvancedSearchForm() { + const isWideScreen = useMediaQuery('(min-width: 768px)'); + const [formExpanded, setFormExpanded] = useState(false); + const [docsExpanded, setDocsExpanded] = useState(false); + + const expandForm = useEvent(() => setFormExpanded(true)); + const expandDocs = useEvent(() => setDocsExpanded(true)); + + useEffect(() => { + if (isWideScreen) { + setFormExpanded(false); + setDocsExpanded(false); + } + }, [isWideScreen]); + + const showFullForm = isWideScreen || formExpanded; + const showFullDocs = isWideScreen || docsExpanded; + + const initialQuery = parseQuery(useSearchQuery()); + + const [query, queryAttrs] = useTextField(initialQuery.text); + const [inPosts, inPostsAttrs] = useCheckboxField(initialQuery.inPosts); + const [inComments, inCommentsAttrs] = useCheckboxField(initialQuery.inComments); + + const [filters, dispatch] = useReducer(reducer, initialQuery.filters); + const ctxValue = useMemo(() => [filters, dispatch], [filters, dispatch]); + + const resultingQuery = useMemo(() => { + if (!inPosts && !inComments) { + return ''; + } + return [ + inPosts && !inComments ? 'in-body:' : '', + inComments && !inPosts ? 'in-comments:' : '', + query.trim(), + ...Object.entries(filters).map(([k, v]) => { + if (intervalFilters.has(k) && !/\d/.test(v)) { + return null; + } + if (usernameFilters.has(k)) { + v = usernames(v); + } + return k + v; + }), + ] + .filter(Boolean) + .join(' '); + }, [inPosts, inComments, query, filters]); + + const onSearch = useEvent(() => + browserHistory.push(`/search?q=${encodeURIComponent(resultingQuery)}`), + ); + + const onKeyDown = useEvent((e) => { + if ( + e.code === 'Enter' && + e.target.matches('input[type="text"], input[type="date"], input[type="search"]') + ) { + e.preventDefault(); + onSearch(); + } + }); + + return ( + +
+
+
+ + +
+
+ Search for: + + +
+
+
+ + + + + + + + +
+
+ + + + + + + + {showFullForm ? ( + <> + + + + + + + + + + + + + + + + + + + ) : null} + +
+ {showFullForm ? ( +
+ + + + + + + + + + + + + + + + + + +
+ ) : null} + {!showFullForm ? ( +

+ + Show all conditions + +

+ ) : null} +
+ +
+ {resultingQuery ? ( +

+ Search query:{' '} + + {resultingQuery} + +

+ ) : null} + {showFullDocs ? ( + <> + {' '} +

+ Use double-quotes to search words in the exact form and specific word order:{' '} + "freefeed version" +
+ Use the asterisk symbol (*) to search word by prefix: free*. The + minimum prefix length is two characters. +
+ Use the pipe symbol (|) between words to search any of them:{' '} + freefeed | version +
+ Use the minus sign (-) to exclude some word from search results:{' '} + freefeed -version +
+ Use the plus sign (+) to specify word order: freefeed + version +
+

+

+ Learn the{' '} + + full query syntax + {' '} + for more advanced search requests. +

+ + ) : ( + <> +

+ Use double-quotes to search words in the exact form and specific word order:{' '} + "freefeed version" +
+ Use the asterisk symbol (*) to search word by prefix: free*. The + minimum prefix length is two characters. +

+

+ + Show search query syntax + help + +

+ + )} +
+
+ ); +} diff --git a/src/components/advanced-search-form/advanced-search-form.module.scss b/src/components/advanced-search-form/advanced-search-form.module.scss new file mode 100644 index 000000000..bb12638ea --- /dev/null +++ b/src/components/advanced-search-form/advanced-search-form.module.scss @@ -0,0 +1,173 @@ +@import '../../../styles/helvetica/dark-vars.scss'; + +.form { + --text-color: #333; + --placeholder-color: #999; + --background-color: #fff; + + :global(.dark-theme) & { + --text-color: #{$text-color}; + --placeholder-color: #777; + --background-color: #{$bg-color}; + } + + input::placeholder { + color: var(--placeholder-color); + } + + label { + margin-bottom: 0; + font-weight: normal; + } + + :global(.checkbox) { + margin: 0; + } +} + +.section { + margin-bottom: 1.5em; +} + +.sectionSticky { + @media (max-width: 767px) { + position: sticky; + bottom: 0; + padding: 0.5em 0; + margin: -0.5em 0 1em; + background: var(--background-color); + } +} + +.sectionTitle { + border-bottom: none; + font-size: 1.2em; + font-weight: bold; + margin-bottom: 0.5em; +} + +.searchInputBox { + display: flex; + gap: 0.3em; + + button { + padding-inline: 2em; + display: none; + + @media (min-width: 768px) { + display: block; + } + } +} + +.searchScopes { + margin-top: 0.5em; + display: flex; + + label { + margin-left: 0.6em; + display: inline-flex; + gap: 0.4em; + cursor: pointer; + } +} + +.columns { + display: flex; + gap: 0.4em; + flex-direction: column; +} + +@media (min-width: 768px) { + .columns { + display: grid; + gap: 0.2em 2em; + grid-auto-flow: column; + grid-template-columns: repeat(2, 1fr); + grid-template-rows: repeat(var(--n-rows), 1fr); + } +} + +.inputRow { + display: flex; + gap: 0 0.5em; + flex-direction: column; + + @media (min-width: 450px) { + flex-direction: row; + align-items: center; + + & > *:first-child { + flex: 0 0 50%; + } + + & > *:last-child { + flex: 1; + } + } +} + +.intervalBox { + display: flex; + gap: 0.25em; + + & > *:first-child { + flex: 0 0 50%; + } + + & > *:last-child { + flex: 1; + } +} + +.expandIcon { + opacity: 0.6; +} + +.textInputBox { + position: relative; +} + +.autocompleteBox { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + + @media (min-width: 768px) { + min-width: 16em; + } +} + +.selector { + color: var(--placeholder-color) !important; + + option[value=''] { + color: var(--placeholder-color); + } + + option:not([value='']) { + color: var(--text-color); + } +} + +.selectorChosen { + color: var(--text-color) !important; +} + +.textInput { + color: var(--placeholder-color) !important; +} + +.textInputChosen { + color: var(--text-color) !important; +} + +.bigSearchButton { + width: 100%; + + @media (min-width: 768px) { + // Width of one column of the form + width: calc(50% - 1em); + } +} diff --git a/src/components/advanced-search-form/bool-input.jsx b/src/components/advanced-search-form/bool-input.jsx new file mode 100644 index 000000000..fe0cb5204 --- /dev/null +++ b/src/components/advanced-search-form/bool-input.jsx @@ -0,0 +1,23 @@ +import { useEvent } from 'react-use-event-hook'; +import { useContext } from 'react'; +import { removeFilter, setFilter } from './reducer'; +import { filtersContext } from './context'; + +export function BoolInput({ label, filter }) { + const [filters, dispatch] = useContext(filtersContext); + const onChange = useEvent((e) => { + if (e.target.checked) { + dispatch(setFilter(filter, '')); + } else { + dispatch(removeFilter(filter)); + } + }); + return ( +
+ +
+ ); +} diff --git a/src/components/advanced-search-form/choose-input.jsx b/src/components/advanced-search-form/choose-input.jsx new file mode 100644 index 000000000..51df360e1 --- /dev/null +++ b/src/components/advanced-search-form/choose-input.jsx @@ -0,0 +1,33 @@ +import cn from 'classnames'; +import { useContext, useId } from 'react'; +import { useEvent } from 'react-use-event-hook'; +import style from './advanced-search-form.module.scss'; +import { filtersContext } from './context'; +import { removeFilter, setFilter } from './reducer'; + +export function ChooseInput({ label, children: options, filter }) { + const id = useId(); + const [filters, dispatch] = useContext(filtersContext); + const onChange = useEvent((e) => { + const v = e.target.value; + if (v.trim() !== '') { + dispatch(setFilter(filter, v)); + } else { + dispatch(removeFilter(filter)); + } + }); + const value = filter in filters ? filters[filter] : ''; + return ( +
+ + +
+ ); +} diff --git a/src/components/advanced-search-form/columns.jsx b/src/components/advanced-search-form/columns.jsx new file mode 100644 index 000000000..deabaf5fb --- /dev/null +++ b/src/components/advanced-search-form/columns.jsx @@ -0,0 +1,26 @@ +import { Children } from 'react'; +import style from './advanced-search-form.module.scss'; + +export function Columns({ children }) { + const nRows = Math.ceil(countElements(children) / 2); + return ( +
+ {children} +
+ ); +} + +function countElements(children) { + let n = 0; + Children.forEach(children, (child) => { + if (!child) { + return; + } + if (child.type === Symbol.for('react.fragment')) { + n += countElements(child.props.children); + } else { + n++; + } + }); + return n; +} diff --git a/src/components/advanced-search-form/context.js b/src/components/advanced-search-form/context.js new file mode 100644 index 000000000..8e5680b8a --- /dev/null +++ b/src/components/advanced-search-form/context.js @@ -0,0 +1,3 @@ +import { createContext } from 'react'; + +export const filtersContext = createContext(); diff --git a/src/components/advanced-search-form/helpers.js b/src/components/advanced-search-form/helpers.js new file mode 100644 index 000000000..d7e3dbfe5 --- /dev/null +++ b/src/components/advanced-search-form/helpers.js @@ -0,0 +1,124 @@ +import { useState } from 'react'; + +export function useTextField(initialValue) { + const [value, setValue] = useState(initialValue); + const attrs = { + value, + onChange: (e) => setValue(e.target.value), + }; + return [value, attrs]; +} + +export function useCheckboxField(initialValue) { + const [value, setValue] = useState(initialValue); + const attrs = { + checked: value, + onChange: (e) => setValue(e.target.checked), + }; + return [value, attrs]; +} + +export const supportedFilters = [ + 'in-my:friends', + 'in-my:discussions', + 'from:me', + 'in-my:messages', + 'in-my:saves', + 'by:me', + 'by:', + 'from:', + 'commented-by:', + 'post-date:>=', + 'post-date:<', + 'in:', + 'is:', + 'has:', + 'comments:', + 'likes:', + 'clikes:', + 'commented-by:', + 'cliked-by:', + '-from:', + '-in:', + '-commented-by:', + '-has:', + '-is:', +]; + +export const intervalFilters = new Set(['comments:', 'likes:', 'clikes:']); +export const usernameFilters = new Set([ + 'by:', + 'from:', + 'commented-by:', + 'liked-by:', + 'cliked-by:', + 'in:', + '-in:', + '-from:', + '-commented-by:', + '-liked-by:', +]); + +export function usernames(text = '') { + text = text.toLowerCase(); + const re = /[a-z\d-]+/g; + let m; + const result = []; + while ((m = re.exec(text))) { + result.push(m[0]); + } + return result.join(','); +} + +/** + * Parses a query string into a query text and an array of conditions + * + * @param {string} query + */ +export function parseQuery(query) { + const result = { + inPosts: true, + inComments: true, + text: '', + filters: {}, + }; + const parts = query.split(/\s+/); + if (parts.length === 0) { + return result; + } + + if (parts[0] === 'in-body:') { + result.inComments = false; + parts.shift(); + } else if (parts[0] === 'in-comments:') { + result.inPosts = false; + parts.shift(); + } + + const textParts = []; + + loop: for (const rawPart of parts) { + const part = rawPart; + for (const filter of supportedFilters) { + if (part === filter) { + if (filter in result.filters) { + textParts.push(rawPart); + } else { + result.filters[filter] = ''; + } + continue loop; + } + if (part.startsWith(filter)) { + if (filter in result.filters) { + textParts.push(rawPart); + } else { + result.filters[filter] = part.slice(filter.length); + } + continue loop; + } + } + textParts.push(rawPart); + } + result.text = textParts.join(' '); + return result; +} diff --git a/src/components/advanced-search-form/interval-input.jsx b/src/components/advanced-search-form/interval-input.jsx new file mode 100644 index 000000000..1747fac87 --- /dev/null +++ b/src/components/advanced-search-form/interval-input.jsx @@ -0,0 +1,54 @@ +import { useContext, useId } from 'react'; +import { useEvent } from 'react-use-event-hook'; +import style from './advanced-search-form.module.scss'; +import { filtersContext } from './context'; +import { setFilter } from './reducer'; + +export function IntervalInput({ label, filter }) { + const id = useId(); + const [filters, dispatch] = useContext(filtersContext); + + const value = filter in filters ? filters[filter] : '>='; + const m = /^(<=?|>=?|=)?(\d+)?$/.exec(value); + let op = '>='; + let num = ''; + if (m) { + op = m[1] || '='; + // eslint-disable-next-line prefer-destructuring + num = m[2] || ''; + } + + const onOpChange = useEvent((e) => { + const v = e.target.value; + dispatch(setFilter(filter, v + num)); + }); + + const onNumChange = useEvent((e) => { + const v = e.target.value; + dispatch(setFilter(filter, op + v)); + }); + + return ( +
+ +
+ + +
+
+ ); +} diff --git a/src/components/advanced-search-form/reducer.js b/src/components/advanced-search-form/reducer.js new file mode 100644 index 000000000..10a0f3396 --- /dev/null +++ b/src/components/advanced-search-form/reducer.js @@ -0,0 +1,23 @@ +import { omit } from 'lodash-es'; + +const SET = 'SET'; +const REMOVE = 'REMOVE'; + +export function reducer(state, action) { + switch (action.type) { + case SET: + return { ...state, [action.filter]: action.value }; + case REMOVE: + return omit(state, action.filter); + default: + return state; + } +} + +export function setFilter(filter, value) { + return { type: SET, filter, value }; +} + +export function removeFilter(filter) { + return { type: REMOVE, filter }; +} diff --git a/src/components/advanced-search-form/section.jsx b/src/components/advanced-search-form/section.jsx new file mode 100644 index 000000000..8a43e24cb --- /dev/null +++ b/src/components/advanced-search-form/section.jsx @@ -0,0 +1,11 @@ +import cn from 'classnames'; +import style from './advanced-search-form.module.scss'; + +export function Section({ title, children, sticky = false }) { + return ( +
+ {title ? {title} : null} + {children} +
+ ); +} diff --git a/src/components/advanced-search-form/text-input.jsx b/src/components/advanced-search-form/text-input.jsx new file mode 100644 index 000000000..d65ed75d8 --- /dev/null +++ b/src/components/advanced-search-form/text-input.jsx @@ -0,0 +1,47 @@ +import cn from 'classnames'; +import { useContext, useId, useRef } from 'react'; +import { useEvent } from 'react-use-event-hook'; +import { Autocomplete } from '../autocomplete/autocomplete'; +import style from './advanced-search-form.module.scss'; +import { filtersContext } from './context'; +import { removeFilter, setFilter } from './reducer'; +import { usernameFilters } from './helpers'; + +const autocompleteAnchor = /^|[^a-z\d]/gi; + +export function TextInput({ label, placeholder = '', type = 'text', filter }) { + const id = useId(); + const [filters, dispatch] = useContext(filtersContext); + const onChange = useEvent((e) => { + const v = e.target.value; + if (v.trim() !== '') { + dispatch(setFilter(filter, v)); + } else { + dispatch(removeFilter(filter)); + } + }); + const value = filter in filters ? filters[filter] : ''; + const input = useRef(); + const withAutocomplete = usernameFilters.has(filter); + return ( +
+ +
+ + {withAutocomplete ? ( +
+ +
+ ) : null} +
+
+ ); +} diff --git a/src/components/autocomplete/autocomplete.jsx b/src/components/autocomplete/autocomplete.jsx index aeb5197ae..b083478a7 100644 --- a/src/components/autocomplete/autocomplete.jsx +++ b/src/components/autocomplete/autocomplete.jsx @@ -105,31 +105,22 @@ export function Autocomplete({ inputRef, context, anchor = defaultAnchor }) { * @returns {[number, number]|null} */ function getQueryPosition({ value, selectionStart }, anchor) { - if (!selectionStart) { - return null; - } - anchor.lastIndex = 0; - - let found = -1; while (anchor.exec(value) !== null) { - if (anchor.lastIndex > selectionStart) { + const pos = anchor.lastIndex; + if (pos > selectionStart) { break; } - found = anchor.lastIndex; - } - if (found === -1) { - return null; - } - - const match = value.slice(found).match(/^[a-z\d-]+/i)?.[0]; - // Check that the caret is inside the match or is at its edge - if (!match || match.length <= selectionStart - found - 1) { - return null; + const match = value.slice(pos).match(/^[a-z\d-]+/i)?.[0]; + // Check that the caret is inside the match or is at its edge + if (match && match.length > selectionStart - pos - 1) { + return [pos, pos + match.length]; + } + anchor.lastIndex = pos + 1; } - return [found, found + match.length]; + return null; } /** diff --git a/src/components/autocomplete/selector.jsx b/src/components/autocomplete/selector.jsx index 43f5d5113..740c32d1f 100644 --- a/src/components/autocomplete/selector.jsx +++ b/src/components/autocomplete/selector.jsx @@ -22,6 +22,7 @@ import { } from './ranked-names'; export function Selector({ query, events, onSelect, context, localLinks = false }) { + query = query.toLowerCase(); const dispatch = useDispatch(); const [usernames, accountsMap, compare] = useAccountsMap({ context }); @@ -37,7 +38,16 @@ export function Selector({ query, events, onSelect, context, localLinks = false }, [dispatch, query]); const matches = useMemo(() => { - const finder = new Finder(query, 5, compare); + const compareWithExact = (a, b) => { + if (a.text === query) { + return -1; + } + if (b.text === query) { + return 1; + } + return compare(a, b); + }; + const finder = new Finder(query, 5, compareWithExact); for (const username of usernames) { finder.add(username); } diff --git a/src/components/layout-header-search.jsx b/src/components/layout-header-search.jsx new file mode 100644 index 000000000..a4b5c25db --- /dev/null +++ b/src/components/layout-header-search.jsx @@ -0,0 +1,145 @@ +import cn from 'classnames'; +import { Link, withRouter } from 'react-router'; +import { faSearch, faSlidersH, faTimesCircle } from '@fortawesome/free-solid-svg-icons'; +import { useState, useEffect, useRef, useMemo } from 'react'; +import { useEvent } from 'react-use-event-hook'; + +import { KEY_ESCAPE } from 'keycode-js'; +import styles from './layout-header.module.scss'; +import { Icon } from './fontawesome-icons'; +import { Autocomplete } from './autocomplete/autocomplete'; +import { useMediaQuery } from './hooks/media-query'; + +const autocompleteAnchor = /(^|[^a-z\d])@|((from|to|author|by|in|commented-?by|liked-?by):)/gi; + +export const HeaderSearchForm = withRouter(function HeaderSearchForm({ router, closeSearchForm }) { + const isWideScreen = useMediaQuery('(min-width: 700px)'); + const isNarrowScreen = useMediaQuery('(max-width: 549px)'); + + const [query, setQuery] = useState(''); + const onQueryChange = useEvent(({ target }) => setQuery(target.value)); + + const fullSearchForm = isWideScreen; + const compactSearchForm = !fullSearchForm; + const collapsibleSearchForm = isNarrowScreen; + + const initialQuery = useInitialQuery(router); + const input = useRef(null); + useEffect(() => void setQuery(initialQuery), [initialQuery]); + + const onSubmit = useEvent((e) => { + e.preventDefault(); + const q = query.trim(); + if (q !== '') { + router.push(`/search?q=${encodeURIComponent(q)}`); + input.current.blur(); + } + }); + + const advancedSearchClick = useEvent(() => document.activeElement?.blur()); + + const onKeyDown = useEvent((e) => e.keyCode === KEY_ESCAPE && input.current.blur()); + const clearSearchForm = useEvent(() => (setQuery(''), input.current.focus())); + + const onBlur = useEvent((e) => { + // When the new focus is outside the search form + if (!e.currentTarget.contains(e.relatedTarget)) { + closeSearchForm(); + } + }); + + useEffect(() => { + const abortController = new AbortController(); + document.addEventListener( + 'keydown', + (e) => { + if (document.activeElement !== document.body) { + return; + } + if ( + // [/] or Ctrl+[K] + (e.code === 'Slash' && !e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey) || + (e.code === 'KeyK' && e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey) + ) { + input.current.focus(); + e.preventDefault(); + } + }, + { signal: abortController.signal }, + ); + return () => abortController.abort(); + }, []); + + return ( +
+ + + + +
+ + + Advanced search options + + +
+
+ {compactSearchForm && } +
+ {fullSearchForm && ( + + )} +
+ ); +}); + +function useInitialQuery(router) { + return useMemo(() => { + const route = router.routes[router.routes.length - 1]; + switch (route.name) { + case 'search': + return (router.location.query.q || router.location.query.qs || '').trim(); + case 'saves': + return `in-my:saves `; + case 'discussions': + return `in-my:discussions `; + case 'direct': + return `in-my:directs `; + case 'userLikes': + return `liked-by:${router.params.userName} `; + case 'userComments': + return `commented-by:${router.params.userName} `; + case 'userFeed': + return `in:${router.params.userName} `; + default: + return ''; + } + }, [router.routes, router.params, router.location]); +} diff --git a/src/components/layout-header.jsx b/src/components/layout-header.jsx index 73db28506..e2cdb95f4 100644 --- a/src/components/layout-header.jsx +++ b/src/components/layout-header.jsx @@ -1,9 +1,8 @@ /* global CONFIG */ -import { IndexLink, Link, withRouter } from 'react-router'; -import { faBars, faSearch, faSignInAlt, faTimesCircle } from '@fortawesome/free-solid-svg-icons'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { IndexLink, Link } from 'react-router'; +import { faBars, faSearch, faSignInAlt } from '@fortawesome/free-solid-svg-icons'; +import { useCallback, useState } from 'react'; import cn from 'classnames'; -import { KEY_ESCAPE } from 'keycode-js'; import { useDispatch, useSelector } from 'react-redux'; import { openSidebar } from '../redux/action-creators'; @@ -11,13 +10,10 @@ import { Icon } from './fontawesome-icons'; import { useMediaQuery } from './hooks/media-query'; import styles from './layout-header.module.scss'; import { SignInLink } from './sign-in-link'; -import { Autocomplete } from './autocomplete/autocomplete'; +import { HeaderSearchForm } from './layout-header-search'; -const autocompleteAnchor = /(^|[^a-z\d])@|((from|to|author|by|in|commented-?by|liked-?by):)/gi; - -export const LayoutHeader = withRouter(function LayoutHeader({ router }) { +export function LayoutHeader() { const dispatch = useDispatch(); - const onSearchPage = router.routes[router.routes.length - 1].name === 'search'; const isLayoutWithSidebar = useMediaQuery('(min-width: 992px)'); const isWideScreen = useMediaQuery('(min-width: 700px)'); const isNarrowScreen = useMediaQuery('(max-width: 549px)'); @@ -25,95 +21,16 @@ export const LayoutHeader = withRouter(function LayoutHeader({ router }) { const authenticated = useSelector((state) => state.authenticated); const [searchExpanded, setSearchExpanded] = useState(false); - const [query, setQuery] = useState(''); - const onQueryChange = useCallback(({ target }) => setQuery(target.value), []); const fullSearchForm = isWideScreen; const compactSearchForm = !fullSearchForm; - const collapsibleSearchForm = isNarrowScreen && (!onSearchPage || searchExpanded); - - useEffect(() => { - if (!collapsibleSearchForm) { - setSearchExpanded(false); - } - }, [collapsibleSearchForm]); + const collapsibleSearchForm = isNarrowScreen; const openSearchForm = useCallback(() => setSearchExpanded(true), []); const closeSearchForm = useCallback(() => setSearchExpanded(false), []); - const initialQuery = useInitialQuery(router); - const input = useRef(null); - useEffect(() => void setQuery(initialQuery), [initialQuery]); - - const onSubmit = useCallback( - (e) => { - e.preventDefault(); - const q = query.trim(); - if (q !== '') { - router.push(`/search?q=${encodeURIComponent(q)}`); - input.current.blur(); - } - }, - [router, query], - ); - - const onKeyDown = useCallback((e) => e.keyCode === KEY_ESCAPE && input.current.blur(), []); - - const clearSearchForm = useCallback(() => (setQuery(''), input.current.focus()), []); - - const onFocus = useCallback( - () => isNarrowScreen && onSearchPage && setSearchExpanded(true), - [isNarrowScreen, onSearchPage], - ); - const doOpenSidebar = useCallback(() => dispatch(openSidebar(true)), [dispatch]); - const focusHandlers = useDebouncedFocus({ - onFocus, - onBlur: closeSearchForm, - }); - - const searchForm = ( -
- - - -
- -
-
- {compactSearchForm && } - -
- {fullSearchForm && ( - - )} -
- ); - const sidebarButton = !isLayoutWithSidebar && (authenticated ? ( @@ -147,7 +64,7 @@ export const LayoutHeader = withRouter(function LayoutHeader({ router }) { > {searchExpanded ? (
- {authenticated && searchForm} + {authenticated ? : null} {sidebarButton}
) : ( @@ -163,7 +80,9 @@ export const LayoutHeader = withRouter(function LayoutHeader({ router }) { )}
- {authenticated && !collapsibleSearchForm && searchForm} + {authenticated && !collapsibleSearchForm ? ( + + ) : null} {authenticated && collapsibleSearchForm && (
{props.entries.length > 0 && ( diff --git a/src/components/search-form-advanced.jsx b/src/components/search-form-advanced.jsx deleted file mode 100644 index edd06c059..000000000 --- a/src/components/search-form-advanced.jsx +++ /dev/null @@ -1,307 +0,0 @@ -/* global CONFIG */ -import { useCallback, useMemo, useEffect } from 'react'; -import { useField, useForm } from 'react-final-form-hooks'; -import { useSelector } from 'react-redux'; -import { withRouter } from 'react-router'; - -import { CheckboxInput, RadioInput } from './form-utils'; -import styles from './search-form-advanced.module.scss'; -import { useSearchQuery } from './hooks/search-query'; - -const SCOPE_ALL = 'all'; -const SCOPE_BODY = 'body'; -const SCOPE_COMMENTS = 'comments'; - -export const SearchFormAdvanced = withRouter(function SearchFormAdvanced({ router }) { - const isAuthorized = useSelector((state) => !!state.user.id); - const queryString = useSearchQuery(); - - const form = useForm( - useMemo( - () => ({ - initialValues: parseQuery(queryString), - onSubmit: (values) => { - const q = generateQuery(values); - q && router.push(`/search?q=${encodeURIComponent(q)}`); - }, - }), - [router, queryString], - ), - ); - - const query = useField('query', form.form); - const inBody = useField('inBody', form.form); - const inComments = useField('inComments', form.form); - const postsFilter = useField('filter', form.form); - const inMy = useField('inMy', form.form); - const inFeeds = useField('in', form.form); - const fromUsers = useField('from', form.form); - const commentedBy = useField('commentedBy', form.form); - const likedBy = useField('likedBy', form.form); - - const scope = useMemo(() => { - if (inBody.input.value && !inComments.input.value) { - return SCOPE_BODY; - } else if (!inBody.input.value && inComments.input.value) { - return SCOPE_COMMENTS; - } - return SCOPE_ALL; - }, [inBody.input.value, inComments.input.value]); - - // author:me isnt available in in-body: scope, so selecting from:me - useEffect(() => { - if (scope === SCOPE_BODY && postsFilter.input.value === 'author:me') { - postsFilter.input.onChange('from:me'); - } - }, [scope, postsFilter.input]); - - const txt = useCallback( - (inAllText, inBodyText, inCommentsText) => { - if (scope === SCOPE_BODY) { - return inBodyText; - } else if (scope === SCOPE_COMMENTS) { - return inCommentsText; - } - return inAllText; - }, - [scope], - ); - - const resultingQuery = generateQuery(form.values); - - return ( -
-
-
- - - - -
-
-
- Search in{' '} - - -
-

Where to search:

-
- -
- {isAuthorized && ( - <> -
- -
-
- -
- {scope !== SCOPE_BODY && ( -
- -
- )} -
- -
- - )} -
- -
-
- -
-
- -
-
- -
-
-
- {resultingQuery && ( -

- Search query: {resultingQuery} -

- )} -

- Use double-quotes to search words in the exact form and specific word order:{' '} - "freefeed version" -
- Use the asterisk symbol (*) to search word by prefix: free*. The - minimum prefix length is two characters. -
- Use the pipe symbol (|) between words to search any of them:{' '} - freefeed | version -
- Use the minus sign (-) to exclude some word from search results:{' '} - freefeed -version -
- Use the plus sign (+) to specify word order: freefeed + version -
-

-

- Learn the{' '} - - full query syntax - {' '} - for more advanced search requests. -

-
- ); -}); - -function usernames(text = '') { - text = text.toLowerCase(); - const re = /[a-z\d-]+/g; - let m; - const result = []; - while ((m = re.exec(text))) { - result.push(m[0]); - } - return result.join(','); -} - -function generateQuery(values) { - const scope = - values.inBody && !values.inComments - ? SCOPE_BODY - : !values.inBody && values.inComments - ? SCOPE_COMMENTS - : SCOPE_ALL; - - const parts = []; - scope === SCOPE_BODY && parts.push('in-body:'); - scope === SCOPE_COMMENTS && parts.push('in-comments:'); - const { filter } = values; - switch (filter) { - case 'in-my:friends': - case 'from:me': - case 'author:me': - parts.push(filter); - break; - case 'in-my:': - parts.push(filter + values.inMy); - break; - case 'in:': { - const v = usernames(values.in); - v && parts.push(filter + v); - break; - } - case 'from:': { - const v = usernames(values.from); - v && parts.push(filter + v); - break; - } - case 'commented-by:': { - const v = usernames(values.commentedBy); - v && parts.push(filter + v); - break; - } - case 'liked-by:': { - const v = usernames(values.likedBy); - v && parts.push(filter + v); - break; - } - } - parts.push(values.query.trim()); - return parts.filter(Boolean).join(' '); -} - -function parseQuery(query) { - const values = { - query: query.trim(), - inBody: true, - inComments: true, - filter: '', - inMy: 'discussions', - inFeeds: '', - from: '', - commentedBy: '', - likedBy: '', - }; - - if (values.query === '') { - return values; - } - - const trimFirstWord = () => (values.query = values.query.replace(/^\S+\s+/, '')); - - if (/^in-body:\s+/.test(values.query)) { - values.inComments = false; - trimFirstWord(); - } else if (/^in-comments:\s+/.test(values.query)) { - values.inBody = false; - trimFirstWord(); - } - - let m = /^(in-my:friends|from:me|author:me)\b/.exec(values.query); - if (m) { - values.filter = m[1]; // eslint-disable-line prefer-destructuring - trimFirstWord(); - return values; - } - - m = /^(in-my:)(discussions|saves|directs)\b/.exec(values.query); - if (m) { - [, values.filter, values.inMy] = m; - trimFirstWord(); - return values; - } - - m = /^(in:|from:|commented-by:|liked-by:)(\S+)\s+/.exec(values.query); - if (m) { - values.filter = m[1]; // eslint-disable-line prefer-destructuring - values[kebabToCamel(m[1].replace(':', ''))] = m[2].replace(',', ', '); - trimFirstWord(); - return values; - } - - return values; -} - -function kebabToCamel(text) { - return text - .split('-') - .map((w, i) => (i ? w.charAt(0).toUpperCase() + w.slice(1) : w)) - .join(''); -} diff --git a/src/components/search-form-advanced.module.scss b/src/components/search-form-advanced.module.scss deleted file mode 100644 index 27cd57713..000000000 --- a/src/components/search-form-advanced.module.scss +++ /dev/null @@ -1,19 +0,0 @@ -.queryInputGroup { - margin-bottom: 0.5em; -} - -.options { - margin-bottom: 1em; - - label { - font-weight: normal; - - &.inline { - margin-right: 1em; - } - } - - :global(.input-group) { - min-height: 2.5em; - } -} diff --git a/styles/common/common.scss b/styles/common/common.scss index 2bd8faa8e..5696f85fa 100644 --- a/styles/common/common.scss +++ b/styles/common/common.scss @@ -17,3 +17,7 @@ pre, samp { font-family: Vazir, Menlo, Monaco, Consolas, 'Courier New', monospace; } + +select.form-control { + padding-inline: 0.6rem; +} diff --git a/styles/helvetica/dark-theme.scss b/styles/helvetica/dark-theme.scss index 10ee27a48..24a3f5d8f 100644 --- a/styles/helvetica/dark-theme.scss +++ b/styles/helvetica/dark-theme.scss @@ -57,6 +57,7 @@ body, } body, + legend, .post .post-body .post-text, .post .post-body, .box-header,