From d187c23a77948256cd035eb662ddd57c6a1f792e Mon Sep 17 00:00:00 2001 From: Athou Date: Thu, 27 Oct 2022 16:25:32 +0200 Subject: [PATCH] add search support --- .../src/app/slices/entries.test.ts | 2 +- commafeed-client/src/app/slices/entries.ts | 58 +++++++++++-------- .../src/components/content/Content.tsx | 33 ++++++++++- .../content/FeedEntryCompactHeader.tsx | 5 +- .../components/content/FeedEntryHeader.tsx | 5 +- .../src/components/content/FeedEntryTitle.tsx | 13 +++++ .../src/components/header/Header.tsx | 40 ++++++++++++- commafeed-client/src/locales/en/messages.po | 6 ++ commafeed-client/src/locales/fr/messages.po | 6 ++ .../src/pages/app/FeedEntriesPage.tsx | 10 +++- 10 files changed, 144 insertions(+), 34 deletions(-) create mode 100644 commafeed-client/src/components/content/FeedEntryTitle.tsx diff --git a/commafeed-client/src/app/slices/entries.test.ts b/commafeed-client/src/app/slices/entries.test.ts index 611a3580..ff62e198 100644 --- a/commafeed-client/src/app/slices/entries.test.ts +++ b/commafeed-client/src/app/slices/entries.test.ts @@ -32,7 +32,7 @@ describe("entries", () => { } as AxiosResponse) const store = configureStore({ reducer: reducers }) - const promise = store.dispatch(loadEntries({ type: "feed", id: "feed-id" })) + const promise = store.dispatch(loadEntries({ source: { type: "feed", id: "feed-id" }, clearSearch: true })) expect(store.getState().entries.source.type).toBe("feed") expect(store.getState().entries.source.id).toBe("feed-id") diff --git a/commafeed-client/src/app/slices/entries.ts b/commafeed-client/src/app/slices/entries.ts index 246107ac..c73bf4e9 100644 --- a/commafeed-client/src/app/slices/entries.ts +++ b/commafeed-client/src/app/slices/entries.ts @@ -27,6 +27,7 @@ interface EntriesState { timestamp?: number selectedEntryId?: string hasMore: boolean + search?: string scrollingToEntry: boolean } @@ -44,38 +45,43 @@ const initialState: EntriesState = { const getEndpoint = (sourceType: EntrySourceType) => sourceType === "category" || sourceType === "tag" ? client.category.getEntries : client.feed.getEntries -export const loadEntries = createAsyncThunk("entries/load", async (source, thunkApi) => { - const state = thunkApi.getState() - const endpoint = getEndpoint(source.type) - const result = await endpoint({ - id: source.type === "tag" ? Constants.categories.all.id : source.id, - order: state.user.settings?.readingOrder, - readType: state.user.settings?.readingMode, - offset: 0, - limit: 50, - tag: source.type === "tag" ? source.id : undefined, - }) - return result.data -}) +export const loadEntries = createAsyncThunk( + "entries/load", + async (arg, thunkApi) => { + if (arg.clearSearch) thunkApi.dispatch(setSearch("")) + + const state = thunkApi.getState() + const endpoint = getEndpoint(arg.source.type) + const result = await endpoint(buildGetEntriesPaginatedRequest(state, arg.source, 0)) + return result.data + } +) export const loadMoreEntries = createAsyncThunk("entries/loadMore", async (_, thunkApi) => { const state = thunkApi.getState() const { source } = state.entries const offset = state.user.settings?.readingMode === "all" ? state.entries.entries.length : state.entries.entries.filter(e => !e.read).length const endpoint = getEndpoint(state.entries.source.type) - const result = await endpoint({ - id: source.type === "tag" ? Constants.categories.all.id : source.id, - readType: state.user.settings?.readingMode, - order: state.user.settings?.readingOrder, - offset, - limit: 50, - tag: source.type === "tag" ? source.id : undefined, - }) + const result = await endpoint(buildGetEntriesPaginatedRequest(state, source, offset)) return result.data }) -export const reloadEntries = createAsyncThunk("entries/reload", async (_, thunkApi) => { +const buildGetEntriesPaginatedRequest = (state: RootState, source: EntrySource, offset: number) => ({ + id: source.type === "tag" ? Constants.categories.all.id : source.id, + order: state.user.settings?.readingOrder, + readType: state.user.settings?.readingMode, + offset, + limit: 50, + tag: source.type === "tag" ? source.id : undefined, + keywords: state.entries.search, +}) +export const reloadEntries = createAsyncThunk("entries/reload", async (arg, thunkApi) => { const state = thunkApi.getState() - thunkApi.dispatch(loadEntries(state.entries.source)) + thunkApi.dispatch(loadEntries({ source: state.entries.source, clearSearch: false })) +}) +export const search = createAsyncThunk("entries/search", async (arg, thunkApi) => { + const state = thunkApi.getState() + thunkApi.dispatch(setSearch(arg)) + thunkApi.dispatch(loadEntries({ source: state.entries.source, clearSearch: false })) }) export const markEntry = createAsyncThunk( "entries/entry/mark", @@ -263,6 +269,9 @@ export const entriesSlice = createSlice({ setScrollingToEntry: (state, action: PayloadAction) => { state.scrollingToEntry = action.payload }, + setSearch: (state, action: PayloadAction) => { + state.search = action.payload + }, }, extraReducers: builder => { builder.addCase(markEntry.pending, (state, action) => { @@ -294,7 +303,7 @@ export const entriesSlice = createSlice({ }) }) builder.addCase(loadEntries.pending, (state, action) => { - state.source = action.meta.arg + state.source = action.meta.arg.source state.entries = [] state.timestamp = undefined state.sourceLabel = "" @@ -325,4 +334,5 @@ export const entriesSlice = createSlice({ }, }) +export const { setSearch } = entriesSlice.actions export default entriesSlice.reducer diff --git a/commafeed-client/src/components/content/Content.tsx b/commafeed-client/src/components/content/Content.tsx index ef06d86f..e031db74 100644 --- a/commafeed-client/src/components/content/Content.tsx +++ b/commafeed-client/src/components/content/Content.tsx @@ -1,8 +1,9 @@ -import { Box, createStyles, TypographyStylesProvider } from "@mantine/core" +import { Box, createStyles, Mark, TypographyStylesProvider } from "@mantine/core" import { Constants } from "app/constants" +import { useAppSelector } from "app/store" import { calculatePlaceholderSize } from "app/utils" import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading" -import { Interweave, TransformCallback } from "interweave" +import { ChildrenNode, Interweave, Matcher, MatchResponse, Node, TransformCallback } from "interweave" export interface ContentProps { content: string @@ -54,13 +55,39 @@ const transform: TransformCallback = node => { return undefined } +class HighlightMatcher extends Matcher { + private search: string + + constructor(search: string) { + super("highlight") + this.search = search + } + + match(string: string): MatchResponse | null { + const pattern = this.search.split(" ").join("|") + return this.doMatch(string, new RegExp(pattern, "i"), () => ({})) + } + + // eslint-disable-next-line class-methods-use-this, @typescript-eslint/no-unused-vars + replaceWith(children: ChildrenNode, props: unknown): Node { + return {children} + } + + // eslint-disable-next-line class-methods-use-this + asTag(): string { + return "span" + } +} + export function Content(props: ContentProps) { const { classes } = useStyles() + const search = useAppSelector(state => state.entries.search) + const matchers = search ? [new HighlightMatcher(search)] : [] return ( - + ) diff --git a/commafeed-client/src/components/content/FeedEntryCompactHeader.tsx b/commafeed-client/src/components/content/FeedEntryCompactHeader.tsx index 4c397dd5..233bdc0f 100644 --- a/commafeed-client/src/components/content/FeedEntryCompactHeader.tsx +++ b/commafeed-client/src/components/content/FeedEntryCompactHeader.tsx @@ -2,6 +2,7 @@ import { Box, createStyles, Image, Text } from "@mantine/core" import { Entry } from "app/types" import { RelativeDate } from "components/RelativeDate" import { OnDesktop } from "components/responsive/OnDesktop" +import { FeedEntryTitle } from "./FeedEntryTitle" export interface FeedEntryHeaderProps { entry: Entry @@ -43,7 +44,9 @@ export function FeedEntryCompactHeader(props: FeedEntryHeaderProps) { {props.entry.feedName} - {props.entry.title} + + + diff --git a/commafeed-client/src/components/content/FeedEntryHeader.tsx b/commafeed-client/src/components/content/FeedEntryHeader.tsx index d9e953c8..f7725dc6 100644 --- a/commafeed-client/src/components/content/FeedEntryHeader.tsx +++ b/commafeed-client/src/components/content/FeedEntryHeader.tsx @@ -1,6 +1,7 @@ import { Box, createStyles, Image, Text } from "@mantine/core" import { Entry } from "app/types" import { RelativeDate } from "components/RelativeDate" +import { FeedEntryTitle } from "./FeedEntryTitle" export interface FeedEntryHeaderProps { entry: Entry @@ -27,7 +28,9 @@ export function FeedEntryHeader(props: FeedEntryHeaderProps) { const { classes } = useStyles(props) return ( - {props.entry.title} + + + feed icon diff --git a/commafeed-client/src/components/content/FeedEntryTitle.tsx b/commafeed-client/src/components/content/FeedEntryTitle.tsx new file mode 100644 index 00000000..485c837b --- /dev/null +++ b/commafeed-client/src/components/content/FeedEntryTitle.tsx @@ -0,0 +1,13 @@ +import { Highlight } from "@mantine/core" +import { useAppSelector } from "app/store" +import { Entry } from "app/types" + +export interface FeedEntryTitleProps { + entry: Entry +} + +export function FeedEntryTitle(props: FeedEntryTitleProps) { + const search = useAppSelector(state => state.entries.search) + const keywords = search?.split(" ") + return {props.entry.title} +} diff --git a/commafeed-client/src/components/header/Header.tsx b/commafeed-client/src/components/header/Header.tsx index 285bab57..86bf24f6 100644 --- a/commafeed-client/src/components/header/Header.tsx +++ b/commafeed-client/src/components/header/Header.tsx @@ -1,11 +1,13 @@ import { t } from "@lingui/macro" -import { Center, Divider, Group } from "@mantine/core" -import { reloadEntries } from "app/slices/entries" +import { Center, Divider, Group, Indicator, Popover, TextInput } from "@mantine/core" +import { useForm } from "@mantine/form" +import { reloadEntries, search } from "app/slices/entries" import { changeReadingMode, changeReadingOrder } from "app/slices/user" import { useAppDispatch, useAppSelector } from "app/store" import { ActionButton } from "components/ActionButtton" import { Loader } from "components/Loader" -import { TbArrowDown, TbArrowUp, TbEye, TbEyeOff, TbRefresh, TbUser } from "react-icons/tb" +import { useEffect } from "react" +import { TbArrowDown, TbArrowUp, TbEye, TbEyeOff, TbRefresh, TbSearch, TbUser } from "react-icons/tb" import { MarkAllAsReadButton } from "./MarkAllAsReadButton" import { ProfileMenu } from "./ProfileMenu" @@ -17,8 +19,22 @@ const iconSize = 18 export function Header() { const settings = useAppSelector(state => state.user.settings) const profile = useAppSelector(state => state.user.profile) + const searchFromStore = useAppSelector(state => state.entries.search) const dispatch = useAppDispatch() + const searchForm = useForm<{ search: string }>({ + validate: { + search: value => (value.length > 0 && value.length < 3 ? t`Search requires at least 3 characters` : null), + }, + }) + const { setValues } = searchForm + + useEffect(() => { + setValues({ + search: searchFromStore, + }) + }, [setValues, searchFromStore]) + if (!settings) return return (
@@ -39,6 +55,24 @@ export function Header() { onClick={() => dispatch(changeReadingOrder(settings.readingOrder === "asc" ? "desc" : "asc"))} /> + + + + } label={t`Search`} /> + + + +
dispatch(search(values.search)))}> + } + autoFocus + /> + +
+
+ } label={profile?.name} />} /> diff --git a/commafeed-client/src/locales/en/messages.po b/commafeed-client/src/locales/en/messages.po index 6a3e1eea..db264c32 100644 --- a/commafeed-client/src/locales/en/messages.po +++ b/commafeed-client/src/locales/en/messages.po @@ -588,11 +588,17 @@ msgstr "Save" msgid "Scroll smoothly when navigating between entries" msgstr "Scroll smoothly when navigating between entries" +#: src/components/header/Header.tsx +#: src/components/header/Header.tsx #: src/components/sidebar/TreeSearch.tsx #: src/components/sidebar/TreeSearch.tsx msgid "Search" msgstr "Search" +#: src/components/header/Header.tsx +msgid "Search requires at least 3 characters" +msgstr "Search requires at least 3 characters" + #: src/components/KeyboardShortcutsHelp.tsx msgid "Set focus on next entry without opening it" msgstr "Set focus on next entry without opening it" diff --git a/commafeed-client/src/locales/fr/messages.po b/commafeed-client/src/locales/fr/messages.po index 5290b454..579b116a 100644 --- a/commafeed-client/src/locales/fr/messages.po +++ b/commafeed-client/src/locales/fr/messages.po @@ -588,11 +588,17 @@ msgstr "Enregistrer" msgid "Scroll smoothly when navigating between entries" msgstr "Défilement animé lors de la navigation entre les entrées" +#: src/components/header/Header.tsx +#: src/components/header/Header.tsx #: src/components/sidebar/TreeSearch.tsx #: src/components/sidebar/TreeSearch.tsx msgid "Search" msgstr "Rechercher" +#: src/components/header/Header.tsx +msgid "Search requires at least 3 characters" +msgstr "La recherche requiert au moins 3 caractères" + #: src/components/KeyboardShortcutsHelp.tsx msgid "Set focus on next entry without opening it" msgstr "Sélectionner l'article suivant sans l'ouvrir" diff --git a/commafeed-client/src/pages/app/FeedEntriesPage.tsx b/commafeed-client/src/pages/app/FeedEntriesPage.tsx index 9e861c91..c220499f 100644 --- a/commafeed-client/src/pages/app/FeedEntriesPage.tsx +++ b/commafeed-client/src/pages/app/FeedEntriesPage.tsx @@ -45,7 +45,15 @@ export function FeedEntriesPage(props: FeedEntriesPageProps) { } useEffect(() => { - dispatch(loadEntries({ type: props.sourceType, id })) + dispatch( + loadEntries({ + source: { + type: props.sourceType, + id, + }, + clearSearch: true, + }) + ) }, [dispatch, props.sourceType, id, location.state]) const noSubscriptions = rootCategory && flattenCategoryTree(rootCategory).every(c => c.feeds.length === 0)