From 97230029f8a0ef512d7b84bfe85b8ff34cc12b73 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Thu, 24 Oct 2024 11:06:48 +0300 Subject: [PATCH 01/25] Create an Advanced Search Form layout --- .../advanced-search-form.jsx | 175 ++++++++++++++++++ .../advanced-search-form.module.scss | 95 ++++++++++ .../advanced-search-form/bool-input.jsx | 10 + .../advanced-search-form/choose-input.jsx | 14 ++ .../advanced-search-form/columns.jsx | 26 +++ .../advanced-search-form/interval-input.jsx | 23 +++ .../advanced-search-form/section.jsx | 11 ++ .../advanced-search-form/text-input.jsx | 12 ++ src/components/search-feed.jsx | 9 +- 9 files changed, 372 insertions(+), 3 deletions(-) create mode 100644 src/components/advanced-search-form/advanced-search-form.jsx create mode 100644 src/components/advanced-search-form/advanced-search-form.module.scss create mode 100644 src/components/advanced-search-form/bool-input.jsx create mode 100644 src/components/advanced-search-form/choose-input.jsx create mode 100644 src/components/advanced-search-form/columns.jsx create mode 100644 src/components/advanced-search-form/interval-input.jsx create mode 100644 src/components/advanced-search-form/section.jsx create mode 100644 src/components/advanced-search-form/text-input.jsx 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..c16283da6 --- /dev/null +++ b/src/components/advanced-search-form/advanced-search-form.jsx @@ -0,0 +1,175 @@ +import { useEffect, 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 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'; + +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; + + return ( +
+
+ +
+ Search for: + + +
+
+
+ + + + + + + + +
+
+ + + + + + + + {showFullForm ? ( + <> + + + + + + + + + + + + + + + + + + + ) : null} + +
+ {showFullForm ? ( +
+ + + + + + + + + + + + + + + + + + + +
+ ) : null} + {!showFullForm ? ( +

+ + Show all conditions + +

+ ) : null} +
+ +
+

+ Search query: foo bar +

+ {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..496cf8e73 --- /dev/null +++ b/src/components/advanced-search-form/advanced-search-form.module.scss @@ -0,0 +1,95 @@ +.form { + 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: white; // TODO: dark mode + } +} + +.sectionTitle { + border-bottom: none; + font-size: 1.2em; + font-weight: bold; + margin-bottom: 0.5em; +} + +.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: 380px) { + 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; +} 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..a912b3d0d --- /dev/null +++ b/src/components/advanced-search-form/bool-input.jsx @@ -0,0 +1,10 @@ +export function BoolInput({ label }) { + 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..af3d50b71 --- /dev/null +++ b/src/components/advanced-search-form/choose-input.jsx @@ -0,0 +1,14 @@ +import { useId } from 'react'; +import style from './advanced-search-form.module.scss'; + +export function ChooseInput({ label, children: options }) { + const id = useId(); + 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/interval-input.jsx b/src/components/advanced-search-form/interval-input.jsx new file mode 100644 index 000000000..a5feacfd1 --- /dev/null +++ b/src/components/advanced-search-form/interval-input.jsx @@ -0,0 +1,23 @@ +import { useId } from 'react'; +import style from './advanced-search-form.module.scss'; + +export function IntervalInput({ label }) { + const id = useId(); + return ( +
+ +
+ + +
+
+ ); +} 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..b86a872e4 --- /dev/null +++ b/src/components/advanced-search-form/text-input.jsx @@ -0,0 +1,12 @@ +import { useId } from 'react'; +import style from './advanced-search-form.module.scss'; + +export function TextInput({ label, placeholder = '', type = 'text' }) { + const id = useId(); + return ( +
+ + +
+ ); +} diff --git a/src/components/search-feed.jsx b/src/components/search-feed.jsx index 439247ade..7aa4de5a5 100644 --- a/src/components/search-feed.jsx +++ b/src/components/search-feed.jsx @@ -12,8 +12,11 @@ import { ButtonLink } from './button-link'; import { Icon } from './fontawesome-icons'; import { SignInLink } from './sign-in-link'; -const SearchFormAdvanced = lazyComponent( - () => import('./search-form-advanced').then((m) => ({ default: m.SearchFormAdvanced })), +const AdvancedSearchForm = lazyComponent( + () => + import('./advanced-search-form/advanced-search-form').then((m) => ({ + default: m.AdvancedSearchForm, + })), { fallback:
Loading form...
, errorMessage: "Couldn't load search form", @@ -68,7 +71,7 @@ function FeedHandler(props) {

)} - {(!queryString || advFormVisible) && } + {(!queryString || advFormVisible) && } {props.entries.length > 0 &&
} {props.entries.length > 0 && ( From 0c9445bfd2a2185c7f1f54299ee431e287cee4b5 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Thu, 24 Oct 2024 18:42:31 +0300 Subject: [PATCH 02/25] Parse and build search query --- .../advanced-search-form.jsx | 352 +++++++++++------- .../advanced-search-form/bool-input.jsx | 17 +- .../advanced-search-form/choose-input.jsx | 23 +- .../advanced-search-form/context.js | 3 + .../advanced-search-form/helpers.js | 121 ++++++ .../advanced-search-form/interval-input.jsx | 45 ++- .../advanced-search-form/reducer.js | 23 ++ .../advanced-search-form/text-input.jsx | 25 +- 8 files changed, 462 insertions(+), 147 deletions(-) create mode 100644 src/components/advanced-search-form/context.js create mode 100644 src/components/advanced-search-form/helpers.js create mode 100644 src/components/advanced-search-form/reducer.js diff --git a/src/components/advanced-search-form/advanced-search-form.jsx b/src/components/advanced-search-form/advanced-search-form.jsx index c16283da6..315e5e653 100644 --- a/src/components/advanced-search-form/advanced-search-form.jsx +++ b/src/components/advanced-search-form/advanced-search-form.jsx @@ -1,9 +1,11 @@ -import { useEffect, useState } from 'react'; +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'; @@ -11,6 +13,16 @@ 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)'); @@ -30,146 +42,222 @@ export function AdvancedSearchForm() { 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)}`), + ); + return ( -
-
- -
- Search for: - - -
-
-
- - - - - - - - -
-
- - - - - - - - {showFullForm ? ( - <> - + +
+
+ +
+ Search for: + + +
+
+
+ + + + + + + + +
+
+ + + + + + + + {showFullForm ? ( + <> + + + + + + + + + + + + + + + + + + + ) : null} + +
+ {showFullForm ? ( +
+ + + + + + + + + + + - - - - - - - - - - - - - ) : null} - -
- {showFullForm ? ( -
- - - - - - - - - - - - - - - - - - - -
- ) : null} - {!showFullForm ? ( -

- - Show all conditions - -

- ) : null} -
- -
-

- Search query: foo bar -

- {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. -

- - ) : ( - <> + +
+ ) : null} + {!showFullForm ? (

- 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 all conditions +

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

- - Show search query syntax - help - + 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/bool-input.jsx b/src/components/advanced-search-form/bool-input.jsx index a912b3d0d..fe0cb5204 100644 --- a/src/components/advanced-search-form/bool-input.jsx +++ b/src/components/advanced-search-form/bool-input.jsx @@ -1,8 +1,21 @@ -export function BoolInput({ label }) { +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 index af3d50b71..6a24b0374 100644 --- a/src/components/advanced-search-form/choose-input.jsx +++ b/src/components/advanced-search-form/choose-input.jsx @@ -1,12 +1,29 @@ -import { useId } from 'react'; +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 }) { +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)); + } + }); return (
- {options}
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..f7ff8611c --- /dev/null +++ b/src/components/advanced-search-form/helpers.js @@ -0,0 +1,121 @@ +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:', + 'in:', + '-in:', + '-from:', + '-commented-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 index a5feacfd1..1747fac87 100644 --- a/src/components/advanced-search-form/interval-input.jsx +++ b/src/components/advanced-search-form/interval-input.jsx @@ -1,22 +1,53 @@ -import { useId } from 'react'; +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 }) { +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/text-input.jsx b/src/components/advanced-search-form/text-input.jsx index b86a872e4..c5ab2193d 100644 --- a/src/components/advanced-search-form/text-input.jsx +++ b/src/components/advanced-search-form/text-input.jsx @@ -1,12 +1,31 @@ -import { useId } from 'react'; +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 TextInput({ label, placeholder = '', type = 'text' }) { +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)); + } + }); return (
- +
); } From 1f75f8e68e99130121f5a9e71cab518d006b07d0 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Fri, 25 Oct 2024 12:44:10 +0300 Subject: [PATCH 03/25] Fix the autocomplete pattern search logic for the zero-width pattern case --- src/components/autocomplete/autocomplete.jsx | 27 +++++++------------- 1 file changed, 9 insertions(+), 18 deletions(-) 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; } /** From c0045850f85eaf02b8e82045adbadaaaa03aa84f Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Fri, 25 Oct 2024 12:52:04 +0300 Subject: [PATCH 04/25] Add autocomplete and make inactive options gray --- .../advanced-search-form.jsx | 8 +--- .../advanced-search-form.module.scss | 44 ++++++++++++++++++- .../advanced-search-form/choose-input.jsx | 6 ++- .../advanced-search-form/text-input.jsx | 34 ++++++++++---- 4 files changed, 74 insertions(+), 18 deletions(-) diff --git a/src/components/advanced-search-form/advanced-search-form.jsx b/src/components/advanced-search-form/advanced-search-form.jsx index 315e5e653..0f99c7fd7 100644 --- a/src/components/advanced-search-form/advanced-search-form.jsx +++ b/src/components/advanced-search-form/advanced-search-form.jsx @@ -123,7 +123,7 @@ export function AdvancedSearchForm() { /> - + {showFullForm ? ( <> @@ -163,11 +163,7 @@ export function AdvancedSearchForm() { placeholder="user1, user2" filter="-from:" /> - + +
+ + {withAutocomplete ? ( +
+ +
+ ) : null} +
); } From a3a09fa235f976804d47b77c19e1c6b0a81b8593 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Fri, 25 Oct 2024 13:03:05 +0300 Subject: [PATCH 05/25] Adapt styles for dark mode --- .../advanced-search-form.module.scss | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/src/components/advanced-search-form/advanced-search-form.module.scss b/src/components/advanced-search-form/advanced-search-form.module.scss index ab6e4ab1b..196239a77 100644 --- a/src/components/advanced-search-form/advanced-search-form.module.scss +++ b/src/components/advanced-search-form/advanced-search-form.module.scss @@ -1,6 +1,19 @@ +@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; @@ -22,7 +35,7 @@ bottom: 0; padding: 0.5em 0; margin: -0.5em 0 1em; - background: white; // TODO: dark mode + background: var(--background-color); } } @@ -113,7 +126,7 @@ } .selector { - color: var(--placeholder-color); + color: var(--placeholder-color) !important; option[value=''] { color: var(--placeholder-color); @@ -125,13 +138,13 @@ } .selectorChosen { - color: var(--text-color); + color: var(--text-color) !important; } .textInput { - color: var(--placeholder-color); + color: var(--placeholder-color) !important; } .textInputChosen { - color: var(--text-color); + color: var(--text-color) !important; } From dd23267cc0046836fd90e59dc5c0b7b2e1e92034 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Fri, 25 Oct 2024 13:09:44 +0300 Subject: [PATCH 06/25] Remove previous advanced search form --- src/components/search-form-advanced.jsx | 307 ------------------ .../search-form-advanced.module.scss | 19 -- 2 files changed, 326 deletions(-) delete mode 100644 src/components/search-form-advanced.jsx delete mode 100644 src/components/search-form-advanced.module.scss 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; - } -} From 1f44651203717836d50da811eedfaaa93cfd6a21 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Fri, 25 Oct 2024 16:44:57 +0300 Subject: [PATCH 07/25] Add "Advanced search options" dropdown --- src/components/layout-header.jsx | 19 ++++++++- src/components/layout-header.module.scss | 52 ++++++++++++++++++++++++ src/components/search-feed.jsx | 16 +++++--- 3 files changed, 81 insertions(+), 6 deletions(-) diff --git a/src/components/layout-header.jsx b/src/components/layout-header.jsx index 73db28506..9db27dd87 100644 --- a/src/components/layout-header.jsx +++ b/src/components/layout-header.jsx @@ -1,17 +1,25 @@ /* global CONFIG */ import { IndexLink, Link, withRouter } from 'react-router'; -import { faBars, faSearch, faSignInAlt, faTimesCircle } from '@fortawesome/free-solid-svg-icons'; +import { + faBars, + faSearch, + faSignInAlt, + faSlidersH, + faTimesCircle, +} from '@fortawesome/free-solid-svg-icons'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import cn from 'classnames'; import { KEY_ESCAPE } from 'keycode-js'; import { useDispatch, useSelector } from 'react-redux'; +import { useEvent } from 'react-use-event-hook'; import { openSidebar } from '../redux/action-creators'; 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 { ButtonLink } from './button-link'; const autocompleteAnchor = /(^|[^a-z\d])@|((from|to|author|by|in|commented-?by|liked-?by):)/gi; @@ -57,6 +65,11 @@ export const LayoutHeader = withRouter(function LayoutHeader({ router }) { [router, query], ); + const showAdvancedSearch = useEvent(() => { + router.push(`/search?q=${encodeURIComponent(query.trim())}&advanced`); + input.current.blur(); + }); + const onKeyDown = useCallback((e) => e.keyCode === KEY_ESCAPE && input.current.blur(), []); const clearSearchForm = useCallback(() => (setQuery(''), input.current.focus()), []); @@ -91,6 +104,10 @@ export const LayoutHeader = withRouter(function LayoutHeader({ router }) { tabIndex={-1} />
+ + + Advanced search options +
diff --git a/src/components/layout-header.module.scss b/src/components/layout-header.module.scss index a0a102e04..489b24c06 100644 --- a/src/components/layout-header.module.scss +++ b/src/components/layout-header.module.scss @@ -108,6 +108,58 @@ $without-sidebar: '(max-width: 991px)'; width: 100%; } +.advancedSearch { + position: absolute; + top: 0; + left: 0; + width: 100%; + border: 1px solid #999; + line-height: 2; + background: white; + box-shadow: 0 2px 4px rgb(0, 0, 0, 0.2); + display: flex; + gap: 0.5em; + padding: 0 0.5em; + align-items: center; + cursor: pointer; + opacity: 1; + translate: 0 0; + transition: all 0.1s; + + &:last-child { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + &:hover { + background-color: #eee; + } + + @starting-style { + opacity: 0; + translate: 0 -1em; + } + + :global(.dark-theme) & { + background-color: $bg-color-lighter; + border-color: $separator-color; + } + + :global(.dark-theme) &:hover { + background-color: $bg-color-lightest; + } +} + +.advancedSearchIcon { + flex: none; + opacity: 0.5; +} + +.searchInputContainer:not(:focus-within) .advancedSearch { + display: none; +} + .searchInput { width: 100%; background-color: transparent; diff --git a/src/components/search-feed.jsx b/src/components/search-feed.jsx index 7aa4de5a5..6c9a7bd75 100644 --- a/src/components/search-feed.jsx +++ b/src/components/search-feed.jsx @@ -24,15 +24,21 @@ const AdvancedSearchForm = lazyComponent( ); function FeedHandler(props) { + const urlQuery = useSelector((state) => state.routing.locationBeforeTransitions.query); const queryString = useSearchQuery(); + const preopenAdvancedForm = !queryString || 'advanced' in urlQuery; + const pageIsLoading = useSelector((state) => state.routeLoadingState); - const [advFormVisible, setAdvFormVisible] = useBool(!queryString); + const [advFormVisible, setAdvFormVisible] = useBool(preopenAdvancedForm); const authenticated = useSelector((state) => state.authenticated); - useEffect( - () => void (pageIsLoading && setAdvFormVisible(false)), - [pageIsLoading, setAdvFormVisible], - ); + useEffect(() => { + if (pageIsLoading) { + setAdvFormVisible(false); + } else { + setAdvFormVisible(preopenAdvancedForm); + } + }, [pageIsLoading, preopenAdvancedForm, setAdvFormVisible]); if (!authenticated) { return ( From 47a02c5b35d1a8d47348decd5c79c4a1cea074ac Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Sat, 26 Oct 2024 14:51:33 +0300 Subject: [PATCH 08/25] Reword form text --- .../advanced-search-form/advanced-search-form.jsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/advanced-search-form/advanced-search-form.jsx b/src/components/advanced-search-form/advanced-search-form.jsx index 0f99c7fd7..d9205b53d 100644 --- a/src/components/advanced-search-form/advanced-search-form.jsx +++ b/src/components/advanced-search-form/advanced-search-form.jsx @@ -114,6 +114,7 @@ export function AdvancedSearchForm() {
+ - {showFullForm ? ( <> @@ -163,7 +163,11 @@ export function AdvancedSearchForm() { placeholder="user1, user2" filter="-from:" /> - + Date: Sat, 26 Oct 2024 14:53:09 +0300 Subject: [PATCH 09/25] Make 'select' paddings consistent with the other inputs --- styles/common/common.scss | 4 ++++ 1 file changed, 4 insertions(+) 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; +} From 3fcdc9438ce8345d1d984500ee65fa4f5fe8b2f1 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Sun, 27 Oct 2024 12:20:58 +0300 Subject: [PATCH 10/25] Add custom placeholder to the top search form --- src/components/layout-header.jsx | 26 +++++++------ src/components/layout-header.module.scss | 47 ++++++++++++++++++++++-- 2 files changed, 59 insertions(+), 14 deletions(-) diff --git a/src/components/layout-header.jsx b/src/components/layout-header.jsx index 9db27dd87..c72d2612a 100644 --- a/src/components/layout-header.jsx +++ b/src/components/layout-header.jsx @@ -95,7 +95,7 @@ export const LayoutHeader = withRouter(function LayoutHeader({ router }) { type="text" name="q" ref={input} - placeholder="Search request" + placeholder="" autoFocus={collapsibleSearchForm} autoComplete="off" value={query} @@ -103,6 +103,20 @@ export const LayoutHeader = withRouter(function LayoutHeader({ router }) { onKeyDown={onKeyDown} tabIndex={-1} /> + +
+ Search request + / +
@@ -112,16 +126,6 @@ export const LayoutHeader = withRouter(function LayoutHeader({ router }) {
{compactSearchForm && } - {fullSearchForm && ( -
- Search request - / -
diff --git a/src/components/layout-header.module.scss b/src/components/layout-header.module.scss index 42cd715b5..b0f16a7aa 100644 --- a/src/components/layout-header.module.scss +++ b/src/components/layout-header.module.scss @@ -282,45 +282,3 @@ $without-sidebar: '(max-width: 991px)'; align-items: center; justify-content: center; } - -.placeholderBox { - --placeholder-color: #999; - - color: var(--placeholder-color); - display: flex; - align-items: center; - justify-content: space-between; - white-space: nowrap; - position: absolute; - inset: 0 0.5em; - pointer-events: none; - - :global(.dark-theme) & { - --placeholder-color: #777; - } - - @media (max-width: 699.9px) { - padding-left: 24px; - } - - .searchInput:not(:placeholder-shown) ~ & { - display: none; - } - - span { - overflow: hidden; - text-overflow: ellipsis; - } - - kbd { - padding: 0 0.3em; - background-color: transparent; - color: inherit; - border: 1px solid; - box-shadow: none; - - @media (max-width: 549px) { - display: none; - } - } -} From cb7acfc64247019e9c0af50053a74f80f1554976 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Wed, 30 Oct 2024 20:12:43 +0300 Subject: [PATCH 15/25] Move top search form to the separate component --- src/components/layout-header-search.jsx | 173 +++++++++++++++++++++++ src/components/layout-header.jsx | 179 +----------------------- 2 files changed, 180 insertions(+), 172 deletions(-) create mode 100644 src/components/layout-header-search.jsx diff --git a/src/components/layout-header-search.jsx b/src/components/layout-header-search.jsx new file mode 100644 index 000000000..f1422b1e4 --- /dev/null +++ b/src/components/layout-header-search.jsx @@ -0,0 +1,173 @@ +import cn from 'classnames'; +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 { withRouter } from 'react-router'; +import { KEY_ESCAPE } from 'keycode-js'; +import styles from './layout-header.module.scss'; +import { Icon } from './fontawesome-icons'; +import { Autocomplete } from './autocomplete/autocomplete'; +import { ButtonLink } from './button-link'; +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 onSearchPage = router.routes[router.routes.length - 1].name === 'search'; + + const [searchExpanded, setSearchExpanded] = useState(false); + const [query, setQuery] = useState(''); + const onQueryChange = useEvent(({ target }) => setQuery(target.value)); + + const fullSearchForm = isWideScreen; + const compactSearchForm = !fullSearchForm; + const collapsibleSearchForm = isNarrowScreen && (!onSearchPage || searchExpanded); + + useEffect(() => { + if (!collapsibleSearchForm) { + setSearchExpanded(false); + } + }, [collapsibleSearchForm]); + + 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 showAdvancedSearch = useEvent(() => { + router.push(`/search?q=${encodeURIComponent(query.trim())}&advanced`); + input.current.blur(); + }); + + const onFocus = useEvent(() => isNarrowScreen && onSearchPage && setSearchExpanded(true)); + const onKeyDown = useEvent((e) => e.keyCode === KEY_ESCAPE && input.current.blur()); + const clearSearchForm = useEvent(() => (setQuery(''), input.current.focus())); + + const focusHandlers = useDebouncedFocus({ + onFocus, + onBlur: 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]); +} + +function useDebouncedFocus({ onFocus: onFocusOrig, onBlur: onBlurOrig }, interval = 100) { + const focusTimer = useRef(0); + const blurTimer = useRef(0); + + const cleanup = useEvent(() => { + window.clearTimeout(blurTimer.current); + window.clearTimeout(focusTimer.current); + }); + useEffect(() => () => cleanup(), [cleanup]); + + const onFocus = useEvent(() => { + cleanup(); + focusTimer.current = window.setTimeout(onFocusOrig, interval); + }); + const onBlur = useEvent(() => { + cleanup(); + blurTimer.current = window.setTimeout(onBlurOrig, interval); + }); + + return { onFocus, onBlur }; +} diff --git a/src/components/layout-header.jsx b/src/components/layout-header.jsx index e3e73d864..f56548bbe 100644 --- a/src/components/layout-header.jsx +++ b/src/components/layout-header.jsx @@ -1,27 +1,16 @@ /* global CONFIG */ import { IndexLink, Link, withRouter } from 'react-router'; -import { - faBars, - faSearch, - faSignInAlt, - faSlidersH, - faTimesCircle, -} from '@fortawesome/free-solid-svg-icons'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +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 { useEvent } from 'react-use-event-hook'; import { openSidebar } from '../redux/action-creators'; 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 { ButtonLink } from './button-link'; - -const autocompleteAnchor = /(^|[^a-z\d])@|((from|to|author|by|in|commented-?by|liked-?by):)/gi; +import { HeaderSearchForm } from './layout-header-search'; export const LayoutHeader = withRouter(function LayoutHeader({ router }) { const dispatch = useDispatch(); @@ -33,126 +22,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 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 showAdvancedSearch = useEvent(() => { - router.push(`/search?q=${encodeURIComponent(query.trim())}&advanced`); - input.current.blur(); - }); - - 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, - }); - - 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(); - }, []); - - const searchForm = ( -
- - - - -
- - - Advanced search options - - -
-
- {compactSearchForm && } -
- {fullSearchForm && ( - - )} -
- ); - const sidebarButton = !isLayoutWithSidebar && (authenticated ? ( @@ -186,7 +65,7 @@ export const LayoutHeader = withRouter(function LayoutHeader({ router }) { > {searchExpanded ? (
- {authenticated && searchForm} + {authenticated ? : null} {sidebarButton}
) : ( @@ -202,7 +81,9 @@ export const LayoutHeader = withRouter(function LayoutHeader({ router }) { )}
- {authenticated && !collapsibleSearchForm && searchForm} + {authenticated && !collapsibleSearchForm ? ( + + ) : null} {authenticated && collapsibleSearchForm && ( +
Search for:
{resultingQuery ? (

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

) : null} {showFullDocs ? ( From 5c207bc16c1ab2f34d67aff0e798023d0039df6a Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Thu, 31 Oct 2024 16:00:55 +0300 Subject: [PATCH 20/25] Make the behavior of the top search form the same on all pages --- src/components/layout-header-search.jsx | 12 ++---------- src/components/layout-header.jsx | 9 ++++----- 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/src/components/layout-header-search.jsx b/src/components/layout-header-search.jsx index f1422b1e4..8a5922573 100644 --- a/src/components/layout-header-search.jsx +++ b/src/components/layout-header-search.jsx @@ -15,21 +15,13 @@ const autocompleteAnchor = /(^|[^a-z\d])@|((from|to|author|by|in|commented-?by|l export const HeaderSearchForm = withRouter(function HeaderSearchForm({ router, closeSearchForm }) { const isWideScreen = useMediaQuery('(min-width: 700px)'); const isNarrowScreen = useMediaQuery('(max-width: 549px)'); - const onSearchPage = router.routes[router.routes.length - 1].name === 'search'; - const [searchExpanded, setSearchExpanded] = useState(false); const [query, setQuery] = useState(''); const onQueryChange = useEvent(({ 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 initialQuery = useInitialQuery(router); const input = useRef(null); @@ -49,7 +41,7 @@ export const HeaderSearchForm = withRouter(function HeaderSearchForm({ router, c input.current.blur(); }); - const onFocus = useEvent(() => isNarrowScreen && onSearchPage && setSearchExpanded(true)); + const onFocus = useEvent(() => {}); // TODO remove const onKeyDown = useEvent((e) => e.keyCode === KEY_ESCAPE && input.current.blur()); const clearSearchForm = useEvent(() => (setQuery(''), input.current.focus())); diff --git a/src/components/layout-header.jsx b/src/components/layout-header.jsx index f56548bbe..e2cdb95f4 100644 --- a/src/components/layout-header.jsx +++ b/src/components/layout-header.jsx @@ -1,5 +1,5 @@ /* global CONFIG */ -import { IndexLink, Link, withRouter } from 'react-router'; +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'; @@ -12,9 +12,8 @@ import styles from './layout-header.module.scss'; import { SignInLink } from './sign-in-link'; import { HeaderSearchForm } from './layout-header-search'; -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,7 +24,7 @@ export const LayoutHeader = withRouter(function LayoutHeader({ router }) { const fullSearchForm = isWideScreen; const compactSearchForm = !fullSearchForm; - const collapsibleSearchForm = isNarrowScreen && (!onSearchPage || searchExpanded); + const collapsibleSearchForm = isNarrowScreen; const openSearchForm = useCallback(() => setSearchExpanded(true), []); const closeSearchForm = useCallback(() => setSearchExpanded(false), []); @@ -106,4 +105,4 @@ export const LayoutHeader = withRouter(function LayoutHeader({ router }) { )} ); -}); +} From 829808797ecec42062342a66ccafb31bcd2617df Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Thu, 31 Oct 2024 16:30:51 +0300 Subject: [PATCH 21/25] Use proper onBlur (focusout) handler in top search form --- src/components/layout-header-search.jsx | 33 +++++-------------------- 1 file changed, 6 insertions(+), 27 deletions(-) diff --git a/src/components/layout-header-search.jsx b/src/components/layout-header-search.jsx index 8a5922573..d56f2e654 100644 --- a/src/components/layout-header-search.jsx +++ b/src/components/layout-header-search.jsx @@ -41,13 +41,14 @@ export const HeaderSearchForm = withRouter(function HeaderSearchForm({ router, c input.current.blur(); }); - const onFocus = useEvent(() => {}); // TODO remove const onKeyDown = useEvent((e) => e.keyCode === KEY_ESCAPE && input.current.blur()); const clearSearchForm = useEvent(() => (setQuery(''), input.current.focus())); - const focusHandlers = useDebouncedFocus({ - onFocus, - onBlur: closeSearchForm, + const onBlur = useEvent((e) => { + // When the new focus is outside the search form + if (!e.currentTarget.contains(e.relatedTarget)) { + closeSearchForm(); + } }); useEffect(() => { @@ -74,7 +75,7 @@ export const HeaderSearchForm = withRouter(function HeaderSearchForm({ router, c return (
- + { - window.clearTimeout(blurTimer.current); - window.clearTimeout(focusTimer.current); - }); - useEffect(() => () => cleanup(), [cleanup]); - - const onFocus = useEvent(() => { - cleanup(); - focusTimer.current = window.setTimeout(onFocusOrig, interval); - }); - const onBlur = useEvent(() => { - cleanup(); - blurTimer.current = window.setTimeout(onBlurOrig, interval); - }); - - return { onFocus, onBlur }; -} From 242e9d087ab72f26bf11d97a564b10a77d2eca3e Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Thu, 31 Oct 2024 17:24:28 +0300 Subject: [PATCH 22/25] Place the exact match on top of the autocomplete variants --- src/components/autocomplete/selector.jsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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); } From 83d20d44b78c8265d018506457100bbf2bd19783 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Thu, 31 Oct 2024 17:46:01 +0300 Subject: [PATCH 23/25] Submit the advanced search form by Enter on text fields --- .../advanced-search-form/advanced-search-form.jsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/components/advanced-search-form/advanced-search-form.jsx b/src/components/advanced-search-form/advanced-search-form.jsx index 346a0dd63..52e725379 100644 --- a/src/components/advanced-search-form/advanced-search-form.jsx +++ b/src/components/advanced-search-form/advanced-search-form.jsx @@ -78,9 +78,19 @@ export function AdvancedSearchForm() { 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 ( -
+
Date: Thu, 31 Oct 2024 21:38:23 +0300 Subject: [PATCH 24/25] Add "Exclude posts liked by users" filter to the advanced form --- src/components/advanced-search-form/advanced-search-form.jsx | 5 +++++ src/components/advanced-search-form/helpers.js | 1 + 2 files changed, 6 insertions(+) diff --git a/src/components/advanced-search-form/advanced-search-form.jsx b/src/components/advanced-search-form/advanced-search-form.jsx index 52e725379..19c55579a 100644 --- a/src/components/advanced-search-form/advanced-search-form.jsx +++ b/src/components/advanced-search-form/advanced-search-form.jsx @@ -194,6 +194,11 @@ export function AdvancedSearchForm() { placeholder="user1, user2" filter="-commented-by:" /> + diff --git a/src/components/advanced-search-form/helpers.js b/src/components/advanced-search-form/helpers.js index 96bdfa995..d7e3dbfe5 100644 --- a/src/components/advanced-search-form/helpers.js +++ b/src/components/advanced-search-form/helpers.js @@ -56,6 +56,7 @@ export const usernameFilters = new Set([ '-in:', '-from:', '-commented-by:', + '-liked-by:', ]); export function usernames(text = '') { From b610d8fb5db79ef36115b67303e242c8758384b4 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Thu, 7 Nov 2024 15:36:14 +0300 Subject: [PATCH 25/25] Use regular Link in the "Advanced search" dropdown --- src/components/layout-header-search.jsx | 17 +++++++++-------- src/components/layout-header.module.scss | 15 +++++++++++---- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/components/layout-header-search.jsx b/src/components/layout-header-search.jsx index d56f2e654..a4b5c25db 100644 --- a/src/components/layout-header-search.jsx +++ b/src/components/layout-header-search.jsx @@ -1,13 +1,13 @@ 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 { withRouter } from 'react-router'; + import { KEY_ESCAPE } from 'keycode-js'; import styles from './layout-header.module.scss'; import { Icon } from './fontawesome-icons'; import { Autocomplete } from './autocomplete/autocomplete'; -import { ButtonLink } from './button-link'; import { useMediaQuery } from './hooks/media-query'; const autocompleteAnchor = /(^|[^a-z\d])@|((from|to|author|by|in|commented-?by|liked-?by):)/gi; @@ -36,10 +36,7 @@ export const HeaderSearchForm = withRouter(function HeaderSearchForm({ router, c } }); - const showAdvancedSearch = useEvent(() => { - router.push(`/search?q=${encodeURIComponent(query.trim())}&advanced`); - 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())); @@ -101,10 +98,14 @@ export const HeaderSearchForm = withRouter(function HeaderSearchForm({ router, c
- + Advanced search options - +
diff --git a/src/components/layout-header.module.scss b/src/components/layout-header.module.scss index b0f16a7aa..b51cfe85f 100644 --- a/src/components/layout-header.module.scss +++ b/src/components/layout-header.module.scss @@ -110,6 +110,7 @@ $without-sidebar: '(max-width: 991px)'; .advancedSearch { position: absolute; + z-index: 1; top: 0; left: 0; width: 100%; @@ -126,16 +127,22 @@ $without-sidebar: '(max-width: 991px)'; translate: 0 0; transition: all 0.1s; - &:last-child { - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; + &:is(*, :hover, :focus) { + // Reset the 'a' style + color: inherit; + text-decoration: none; } &:is(:hover, :focus) { background-color: #eee; } + &:last-child { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + @starting-style { opacity: 0; translate: 0 -1em;