mirror of
https://github.com/Athou/commafeed.git
synced 2026-03-21 21:37:29 +00:00
add search support
This commit is contained in:
@@ -32,7 +32,7 @@ describe("entries", () => {
|
|||||||
} as AxiosResponse<Entries>)
|
} as AxiosResponse<Entries>)
|
||||||
|
|
||||||
const store = configureStore({ reducer: reducers })
|
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.type).toBe("feed")
|
||||||
expect(store.getState().entries.source.id).toBe("feed-id")
|
expect(store.getState().entries.source.id).toBe("feed-id")
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ interface EntriesState {
|
|||||||
timestamp?: number
|
timestamp?: number
|
||||||
selectedEntryId?: string
|
selectedEntryId?: string
|
||||||
hasMore: boolean
|
hasMore: boolean
|
||||||
|
search?: string
|
||||||
scrollingToEntry: boolean
|
scrollingToEntry: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,38 +45,43 @@ const initialState: EntriesState = {
|
|||||||
|
|
||||||
const getEndpoint = (sourceType: EntrySourceType) =>
|
const getEndpoint = (sourceType: EntrySourceType) =>
|
||||||
sourceType === "category" || sourceType === "tag" ? client.category.getEntries : client.feed.getEntries
|
sourceType === "category" || sourceType === "tag" ? client.category.getEntries : client.feed.getEntries
|
||||||
export const loadEntries = createAsyncThunk<Entries, EntrySource, { state: RootState }>("entries/load", async (source, thunkApi) => {
|
export const loadEntries = createAsyncThunk<Entries, { source: EntrySource; clearSearch: boolean }, { state: RootState }>(
|
||||||
const state = thunkApi.getState()
|
"entries/load",
|
||||||
const endpoint = getEndpoint(source.type)
|
async (arg, thunkApi) => {
|
||||||
const result = await endpoint({
|
if (arg.clearSearch) thunkApi.dispatch(setSearch(""))
|
||||||
id: source.type === "tag" ? Constants.categories.all.id : source.id,
|
|
||||||
order: state.user.settings?.readingOrder,
|
const state = thunkApi.getState()
|
||||||
readType: state.user.settings?.readingMode,
|
const endpoint = getEndpoint(arg.source.type)
|
||||||
offset: 0,
|
const result = await endpoint(buildGetEntriesPaginatedRequest(state, arg.source, 0))
|
||||||
limit: 50,
|
return result.data
|
||||||
tag: source.type === "tag" ? source.id : undefined,
|
}
|
||||||
})
|
)
|
||||||
return result.data
|
|
||||||
})
|
|
||||||
export const loadMoreEntries = createAsyncThunk<Entries, void, { state: RootState }>("entries/loadMore", async (_, thunkApi) => {
|
export const loadMoreEntries = createAsyncThunk<Entries, void, { state: RootState }>("entries/loadMore", async (_, thunkApi) => {
|
||||||
const state = thunkApi.getState()
|
const state = thunkApi.getState()
|
||||||
const { source } = state.entries
|
const { source } = state.entries
|
||||||
const offset =
|
const offset =
|
||||||
state.user.settings?.readingMode === "all" ? state.entries.entries.length : state.entries.entries.filter(e => !e.read).length
|
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 endpoint = getEndpoint(state.entries.source.type)
|
||||||
const result = await endpoint({
|
const result = await endpoint(buildGetEntriesPaginatedRequest(state, source, offset))
|
||||||
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,
|
|
||||||
})
|
|
||||||
return result.data
|
return result.data
|
||||||
})
|
})
|
||||||
export const reloadEntries = createAsyncThunk<void, void, { state: RootState }>("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<void, void, { state: RootState }>("entries/reload", async (arg, thunkApi) => {
|
||||||
const state = thunkApi.getState()
|
const state = thunkApi.getState()
|
||||||
thunkApi.dispatch(loadEntries(state.entries.source))
|
thunkApi.dispatch(loadEntries({ source: state.entries.source, clearSearch: false }))
|
||||||
|
})
|
||||||
|
export const search = createAsyncThunk<void, string, { state: RootState }>("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(
|
export const markEntry = createAsyncThunk(
|
||||||
"entries/entry/mark",
|
"entries/entry/mark",
|
||||||
@@ -263,6 +269,9 @@ export const entriesSlice = createSlice({
|
|||||||
setScrollingToEntry: (state, action: PayloadAction<boolean>) => {
|
setScrollingToEntry: (state, action: PayloadAction<boolean>) => {
|
||||||
state.scrollingToEntry = action.payload
|
state.scrollingToEntry = action.payload
|
||||||
},
|
},
|
||||||
|
setSearch: (state, action: PayloadAction<string>) => {
|
||||||
|
state.search = action.payload
|
||||||
|
},
|
||||||
},
|
},
|
||||||
extraReducers: builder => {
|
extraReducers: builder => {
|
||||||
builder.addCase(markEntry.pending, (state, action) => {
|
builder.addCase(markEntry.pending, (state, action) => {
|
||||||
@@ -294,7 +303,7 @@ export const entriesSlice = createSlice({
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
builder.addCase(loadEntries.pending, (state, action) => {
|
builder.addCase(loadEntries.pending, (state, action) => {
|
||||||
state.source = action.meta.arg
|
state.source = action.meta.arg.source
|
||||||
state.entries = []
|
state.entries = []
|
||||||
state.timestamp = undefined
|
state.timestamp = undefined
|
||||||
state.sourceLabel = ""
|
state.sourceLabel = ""
|
||||||
@@ -325,4 +334,5 @@ export const entriesSlice = createSlice({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const { setSearch } = entriesSlice.actions
|
||||||
export default entriesSlice.reducer
|
export default entriesSlice.reducer
|
||||||
|
|||||||
@@ -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 { Constants } from "app/constants"
|
||||||
|
import { useAppSelector } from "app/store"
|
||||||
import { calculatePlaceholderSize } from "app/utils"
|
import { calculatePlaceholderSize } from "app/utils"
|
||||||
import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading"
|
import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading"
|
||||||
import { Interweave, TransformCallback } from "interweave"
|
import { ChildrenNode, Interweave, Matcher, MatchResponse, Node, TransformCallback } from "interweave"
|
||||||
|
|
||||||
export interface ContentProps {
|
export interface ContentProps {
|
||||||
content: string
|
content: string
|
||||||
@@ -54,13 +55,39 @@ const transform: TransformCallback = node => {
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class HighlightMatcher extends Matcher {
|
||||||
|
private search: string
|
||||||
|
|
||||||
|
constructor(search: string) {
|
||||||
|
super("highlight")
|
||||||
|
this.search = search
|
||||||
|
}
|
||||||
|
|
||||||
|
match(string: string): MatchResponse<unknown> | 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 <Mark>{children}</Mark>
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line class-methods-use-this
|
||||||
|
asTag(): string {
|
||||||
|
return "span"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function Content(props: ContentProps) {
|
export function Content(props: ContentProps) {
|
||||||
const { classes } = useStyles()
|
const { classes } = useStyles()
|
||||||
|
const search = useAppSelector(state => state.entries.search)
|
||||||
|
const matchers = search ? [new HighlightMatcher(search)] : []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TypographyStylesProvider>
|
<TypographyStylesProvider>
|
||||||
<Box className={classes.content}>
|
<Box className={classes.content}>
|
||||||
<Interweave content={props.content} transform={transform} />
|
<Interweave content={props.content} transform={transform} matchers={matchers} />
|
||||||
</Box>
|
</Box>
|
||||||
</TypographyStylesProvider>
|
</TypographyStylesProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Box, createStyles, Image, Text } from "@mantine/core"
|
|||||||
import { Entry } from "app/types"
|
import { Entry } from "app/types"
|
||||||
import { RelativeDate } from "components/RelativeDate"
|
import { RelativeDate } from "components/RelativeDate"
|
||||||
import { OnDesktop } from "components/responsive/OnDesktop"
|
import { OnDesktop } from "components/responsive/OnDesktop"
|
||||||
|
import { FeedEntryTitle } from "./FeedEntryTitle"
|
||||||
|
|
||||||
export interface FeedEntryHeaderProps {
|
export interface FeedEntryHeaderProps {
|
||||||
entry: Entry
|
entry: Entry
|
||||||
@@ -43,7 +44,9 @@ export function FeedEntryCompactHeader(props: FeedEntryHeaderProps) {
|
|||||||
{props.entry.feedName}
|
{props.entry.feedName}
|
||||||
</Text>
|
</Text>
|
||||||
</OnDesktop>
|
</OnDesktop>
|
||||||
<Box className={classes.title}>{props.entry.title}</Box>
|
<Box className={classes.title}>
|
||||||
|
<FeedEntryTitle entry={props.entry} />
|
||||||
|
</Box>
|
||||||
<OnDesktop>
|
<OnDesktop>
|
||||||
<Text color="dimmed" className={classes.date}>
|
<Text color="dimmed" className={classes.date}>
|
||||||
<RelativeDate date={props.entry.date} />
|
<RelativeDate date={props.entry.date} />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Box, createStyles, Image, Text } from "@mantine/core"
|
import { Box, createStyles, Image, Text } from "@mantine/core"
|
||||||
import { Entry } from "app/types"
|
import { Entry } from "app/types"
|
||||||
import { RelativeDate } from "components/RelativeDate"
|
import { RelativeDate } from "components/RelativeDate"
|
||||||
|
import { FeedEntryTitle } from "./FeedEntryTitle"
|
||||||
|
|
||||||
export interface FeedEntryHeaderProps {
|
export interface FeedEntryHeaderProps {
|
||||||
entry: Entry
|
entry: Entry
|
||||||
@@ -27,7 +28,9 @@ export function FeedEntryHeader(props: FeedEntryHeaderProps) {
|
|||||||
const { classes } = useStyles(props)
|
const { classes } = useStyles(props)
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Box className={classes.headerText}>{props.entry.title}</Box>
|
<Box className={classes.headerText}>
|
||||||
|
<FeedEntryTitle entry={props.entry} />
|
||||||
|
</Box>
|
||||||
<Box className={classes.headerSubtext}>
|
<Box className={classes.headerSubtext}>
|
||||||
<Box mr={6}>
|
<Box mr={6}>
|
||||||
<Image withPlaceholder src={props.entry.iconUrl} alt="feed icon" width={18} height={18} />
|
<Image withPlaceholder src={props.entry.iconUrl} alt="feed icon" width={18} height={18} />
|
||||||
|
|||||||
13
commafeed-client/src/components/content/FeedEntryTitle.tsx
Normal file
13
commafeed-client/src/components/content/FeedEntryTitle.tsx
Normal file
@@ -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 <Highlight highlight={keywords ?? ""}>{props.entry.title}</Highlight>
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import { t } from "@lingui/macro"
|
import { t } from "@lingui/macro"
|
||||||
import { Center, Divider, Group } from "@mantine/core"
|
import { Center, Divider, Group, Indicator, Popover, TextInput } from "@mantine/core"
|
||||||
import { reloadEntries } from "app/slices/entries"
|
import { useForm } from "@mantine/form"
|
||||||
|
import { reloadEntries, search } from "app/slices/entries"
|
||||||
import { changeReadingMode, changeReadingOrder } from "app/slices/user"
|
import { changeReadingMode, changeReadingOrder } from "app/slices/user"
|
||||||
import { useAppDispatch, useAppSelector } from "app/store"
|
import { useAppDispatch, useAppSelector } from "app/store"
|
||||||
import { ActionButton } from "components/ActionButtton"
|
import { ActionButton } from "components/ActionButtton"
|
||||||
import { Loader } from "components/Loader"
|
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 { MarkAllAsReadButton } from "./MarkAllAsReadButton"
|
||||||
import { ProfileMenu } from "./ProfileMenu"
|
import { ProfileMenu } from "./ProfileMenu"
|
||||||
|
|
||||||
@@ -17,8 +19,22 @@ const iconSize = 18
|
|||||||
export function Header() {
|
export function Header() {
|
||||||
const settings = useAppSelector(state => state.user.settings)
|
const settings = useAppSelector(state => state.user.settings)
|
||||||
const profile = useAppSelector(state => state.user.profile)
|
const profile = useAppSelector(state => state.user.profile)
|
||||||
|
const searchFromStore = useAppSelector(state => state.entries.search)
|
||||||
const dispatch = useAppDispatch()
|
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 <Loader />
|
if (!settings) return <Loader />
|
||||||
return (
|
return (
|
||||||
<Center>
|
<Center>
|
||||||
@@ -39,6 +55,24 @@ export function Header() {
|
|||||||
onClick={() => dispatch(changeReadingOrder(settings.readingOrder === "asc" ? "desc" : "asc"))}
|
onClick={() => dispatch(changeReadingOrder(settings.readingOrder === "asc" ? "desc" : "asc"))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Popover>
|
||||||
|
<Popover.Target>
|
||||||
|
<Indicator disabled={!searchFromStore}>
|
||||||
|
<ActionButton icon={<TbSearch size={iconSize} />} label={t`Search`} />
|
||||||
|
</Indicator>
|
||||||
|
</Popover.Target>
|
||||||
|
<Popover.Dropdown>
|
||||||
|
<form onSubmit={searchForm.onSubmit(values => dispatch(search(values.search)))}>
|
||||||
|
<TextInput
|
||||||
|
placeholder={t`Search`}
|
||||||
|
{...searchForm.getInputProps("search")}
|
||||||
|
icon={<TbSearch size={iconSize} />}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Popover.Dropdown>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
<HeaderDivider />
|
<HeaderDivider />
|
||||||
|
|
||||||
<ProfileMenu control={<ActionButton icon={<TbUser size={iconSize} />} label={profile?.name} />} />
|
<ProfileMenu control={<ActionButton icon={<TbUser size={iconSize} />} label={profile?.name} />} />
|
||||||
|
|||||||
@@ -588,11 +588,17 @@ msgstr "Save"
|
|||||||
msgid "Scroll smoothly when navigating between entries"
|
msgid "Scroll smoothly when navigating between entries"
|
||||||
msgstr "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
|
||||||
#: src/components/sidebar/TreeSearch.tsx
|
#: src/components/sidebar/TreeSearch.tsx
|
||||||
msgid "Search"
|
msgid "Search"
|
||||||
msgstr "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
|
#: src/components/KeyboardShortcutsHelp.tsx
|
||||||
msgid "Set focus on next entry without opening it"
|
msgid "Set focus on next entry without opening it"
|
||||||
msgstr "Set focus on next entry without opening it"
|
msgstr "Set focus on next entry without opening it"
|
||||||
|
|||||||
@@ -588,11 +588,17 @@ msgstr "Enregistrer"
|
|||||||
msgid "Scroll smoothly when navigating between entries"
|
msgid "Scroll smoothly when navigating between entries"
|
||||||
msgstr "Défilement animé lors de la navigation entre les entrées"
|
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
|
||||||
#: src/components/sidebar/TreeSearch.tsx
|
#: src/components/sidebar/TreeSearch.tsx
|
||||||
msgid "Search"
|
msgid "Search"
|
||||||
msgstr "Rechercher"
|
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
|
#: src/components/KeyboardShortcutsHelp.tsx
|
||||||
msgid "Set focus on next entry without opening it"
|
msgid "Set focus on next entry without opening it"
|
||||||
msgstr "Sélectionner l'article suivant sans l'ouvrir"
|
msgstr "Sélectionner l'article suivant sans l'ouvrir"
|
||||||
|
|||||||
@@ -45,7 +45,15 @@ export function FeedEntriesPage(props: FeedEntriesPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(loadEntries({ type: props.sourceType, id }))
|
dispatch(
|
||||||
|
loadEntries({
|
||||||
|
source: {
|
||||||
|
type: props.sourceType,
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
clearSearch: true,
|
||||||
|
})
|
||||||
|
)
|
||||||
}, [dispatch, props.sourceType, id, location.state])
|
}, [dispatch, props.sourceType, id, location.state])
|
||||||
|
|
||||||
const noSubscriptions = rootCategory && flattenCategoryTree(rootCategory).every(c => c.feeds.length === 0)
|
const noSubscriptions = rootCategory && flattenCategoryTree(rootCategory).every(c => c.feeds.length === 0)
|
||||||
|
|||||||
Reference in New Issue
Block a user