diff --git a/icons/mgc/rss_cute_fi.svg b/icons/mgc/rss_cute_fi.svg new file mode 100644 index 000000000..770ba82c0 --- /dev/null +++ b/icons/mgc/rss_cute_fi.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/renderer/src/components/ui/scroll-area/ScrollArea.tsx b/src/renderer/src/components/ui/scroll-area/ScrollArea.tsx index 3abe8418c..3a7c12513 100644 --- a/src/renderer/src/components/ui/scroll-area/ScrollArea.tsx +++ b/src/renderer/src/components/ui/scroll-area/ScrollArea.tsx @@ -96,19 +96,18 @@ export const Root = React.forwardRef< )) Root.displayName = "ScrollArea.Root" -export const ScrollArea: React.FC< +export const ScrollArea = React.forwardRef< + HTMLDivElement, React.PropsWithChildren & { rootClassName?: string viewportClassName?: string scrollbarClassName?: string } -> = ({ children, rootClassName, viewportClassName, scrollbarClassName }) => ( +>(({ children, rootClassName, viewportClassName, scrollbarClassName }, ref) => ( - + {children} - + -) +)) diff --git a/src/renderer/src/components/ui/select.tsx b/src/renderer/src/components/ui/select.tsx index 45d54088f..cb4e89e9f 100644 --- a/src/renderer/src/components/ui/select.tsx +++ b/src/renderer/src/components/ui/select.tsx @@ -27,7 +27,7 @@ const SelectTrigger = React.forwardRef< > {children} - + )) @@ -119,7 +119,7 @@ const SelectItem = React.forwardRef< { [active, navigate], ) } -export function FeedColumn() { +export function FeedColumn({ children }: PropsWithChildren) { const carouselRef = useRef(null) const [active, setActive_] = useSidebarActiveView() @@ -195,14 +194,9 @@ export function FeedColumn() { ))} - {APP_VERSION?.[0] === "0" && ( -
- Early Access -
- )} - - + {children} + ) } diff --git a/src/renderer/src/modules/search/cmdk.tsx b/src/renderer/src/modules/search/cmdk.tsx new file mode 100644 index 000000000..199d6b187 --- /dev/null +++ b/src/renderer/src/modules/search/cmdk.tsx @@ -0,0 +1,289 @@ +import { EmptyIcon } from "@renderer/components/icons/empty" +import { Logo } from "@renderer/components/icons/logo" +import { SiteIcon } from "@renderer/components/site-icon" +import { ScrollArea } from "@renderer/components/ui/scroll-area" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@renderer/components/ui/select" +import { useNavigateEntry } from "@renderer/hooks/biz/useNavigateEntry" +import { ROUTE_ENTRY_PENDING } from "@renderer/lib/constants" +import { cn } from "@renderer/lib/utils" +import { getFeedById } from "@renderer/store/feed" +import { searchActions, useSearchStore } from "@renderer/store/search" +import { SearchType } from "@renderer/store/search/constants" +import type { SearchInstance } from "@renderer/store/search/types" +import { useFeedUnreadStore } from "@renderer/store/unread" +import clsx from "clsx" +import { Command } from "cmdk" +import type { FC } from "react" +import * as React from "react" +import { memo, useMemo } from "react" +import { useHotkeys } from "react-hotkeys-hook" + +const SearchCmdKContext = React.createContext | null>( + null, +) +export const SearchCmdK: React.FC = () => { + const [open, setOpen] = React.useState(false) + useHotkeys("meta+k,ctrl+k", () => { + setOpen((o) => !o) + }) + const searchInstance = useMemo(() => searchActions.createLocalDbSearch(), []) + + const entries = useSearchStore((s) => s.entries) + const feeds = useSearchStore((s) => s.feeds) + + const inputRef = React.useRef(null) + const dialogRef = React.useRef(null) + const scrollViewRef = React.useRef(null) + + React.useEffect(() => { + const $input = inputRef.current + if (open && $input) { + $input.focus() + } + }, [open]) + const handleKeyDownToFocusInput: React.EventHandler = + React.useCallback((e) => { + const $input = inputRef.current + if (e.key === "Escape") { + setOpen(false) + return + } + + if (e.key === "ArrowDown" || e.key === "ArrowUp") return + + if (!e.ctrlKey && !e.metaKey && !e.altKey) { + $input?.focus() + } + }, []) + return ( + + + { + const { search } = await searchInstance + search(value) + const $scrollView = scrollViewRef.current + if ($scrollView) { + $scrollView.scrollTop = 0 + } + }} + /> + + + + + + {entries.length > 0 && ( + + )} + className="flex w-full min-w-0 flex-col py-2" + > + {entries.map((entry, index) => { + const feed = getFeedById(entry.feedId) + return ( + + ) + })} + + )} + {feeds.length > 0 && ( + + )} + className="py-2" + > + {feeds.map((feed, index) => ( + + ))} + + )} + + + + + + ) +} + +type SearchListType = { + title: string + subtitle?: Nullable + feedId?: string + entryId?: string + icon?: Nullable + id: string +} + +const SearchItem = memo(function Item({ + index, + ...item +}: { + index: number +} & SearchListType) { + const navigateEntry = useNavigateEntry() + return ( + { + navigateEntry({ + feedId: item.feedId!, + entryId: item.entryId, + }) + }} + > +
+ {item.icon && ( + + )} + + {item.title} + + + {item.subtitle} + +
+
+ ) +}) + +const SearchGroupHeading: FC<{ icon: string, title: string }> = ({ + icon, + title, +}) => ( +
+ + {title} +
+) + +const SearchOptions = () => { + const searchType = useSearchStore((s) => s.searchType) + + const searchInstance = React.useContext(SearchCmdKContext) + const hasKeyword = useSearchStore((s) => !!s.keyword) + return ( +
+ Search Type + + + + {hasKeyword && ( + + This search run on local database, the result may not be up-to-date. + + )} +
+ ) +} + +const SearchPlaceholder = () => { + const hasKeyword = useSearchStore((s) => !!s.keyword) + return ( + + {hasKeyword ? ( +
+ + No results found. +
+ ) : ( + + )} +
+ ) +} diff --git a/src/renderer/src/pages/(main)/layout.tsx b/src/renderer/src/pages/(main)/layout.tsx index f8a674d27..100aa772b 100644 --- a/src/renderer/src/pages/(main)/layout.tsx +++ b/src/renderer/src/pages/(main)/layout.tsx @@ -4,8 +4,11 @@ import { DeclarativeModal } from "@renderer/components/ui/modal/stacked/declarat import { NoopChildren } from "@renderer/components/ui/modal/stacked/utils" import { RootPortal } from "@renderer/components/ui/portal" import { preventDefault } from "@renderer/lib/dom" +import { NetworkStatusIndicator } from "@renderer/modules/app/NetworkStatusIndicator" import { LoginModalContent } from "@renderer/modules/auth/LoginModalContent" import { FeedColumn } from "@renderer/modules/feed-column" +import { AutoUpdater } from "@renderer/modules/feed-column/auto-updater" +import { SearchCmdK } from "@renderer/modules/search/cmdk" import { Outlet } from "react-router-dom" export function Component() { @@ -15,7 +18,16 @@ export function Component() { return (
- + + {APP_VERSION?.[0] === "0" && ( +
+ Early Access +
+ )} + + + +
{/* NOTE: tabIndex for main element can get by `document.activeElement` */}
+ + {isAuthFail && !user && ( } + +type IdToIdRecord = Record +type IdToBooleanRecord = Record +type IdToAnyObjectRecord = Record> class ServiceStatic { - async findAll( - type: EntryRelatedKey, - ): Promise> { + async findAll(type: EntryRelatedKey.FEED_ID): Promise + async findAll(type: EntryRelatedKey.READ): Promise + async findAll(type: EntryRelatedKey.COLLECTION): Promise + + async findAll(type: EntryRelatedKey): Promise> { const data = await entryRelatedModel.table.get(type) return data ? data.data : {} } @@ -24,7 +30,19 @@ class ServiceStatic { * @param data key is entryId, value is read status * @returns */ - async upsert(type: EntryRelatedKey, data: Record) { + async upsert( + type: EntryRelatedKey.READ, + data: IdToBooleanRecord + ): Promise + async upsert( + type: EntryRelatedKey.FEED_ID, + data: IdToIdRecord + ): Promise + async upsert( + type: EntryRelatedKey.COLLECTION, + data: IdToAnyObjectRecord + ): Promise + async upsert(type: any, data: Record) { const oldData = await this.findAll(type) return entryRelatedModel.table.put({ @@ -33,11 +51,8 @@ class ServiceStatic { }) } - async deleteItem( - type: EntryRelatedKey, - key: string, - ) { - const oldData = await this.findAll(type) + async deleteItem(type: EntryRelatedKey, key: string) { + const oldData = await this.findAll(type as any) delete oldData[key] return entryRelatedModel.table.put({ diff --git a/src/renderer/src/services/entry.ts b/src/renderer/src/services/entry.ts index 36d86d365..70cb6fe7d 100644 --- a/src/renderer/src/services/entry.ts +++ b/src/renderer/src/services/entry.ts @@ -1,13 +1,10 @@ import { entryModel } from "@renderer/database/models" import type { - CombinedEntryModel, EntryModel, - FeedModel, } from "@renderer/models/types" import { BaseService } from "./base" import { EntryRelatedKey, EntryRelatedService } from "./entry-related" -import { FeedService } from "./feed" type EntryCollection = { createdAt: string @@ -17,21 +14,6 @@ class EntryServiceStatic extends BaseService { super(entryModel.table) } - pour(data: CombinedEntryModel[]) { - const entries = [] as EntryModel[] - const feeds = [] as FeedModel[] - for (const entry of data) { - entries.push(entry.entries) - - feeds.push(entry.feeds) - } - - return Promise.all([ - this.upsertMany(entries), - FeedService.upsertMany(feeds), - ]) - } - bulkStoreReadStatus(record: Record) { return EntryRelatedService.upsert(EntryRelatedKey.READ, record) } diff --git a/src/renderer/src/store/entry/store.ts b/src/renderer/src/store/entry/store.ts index 57bbac86f..5706de151 100644 --- a/src/renderer/src/store/entry/store.ts +++ b/src/renderer/src/store/entry/store.ts @@ -6,8 +6,6 @@ import type { FeedModel, } from "@renderer/models" import { EntryService } from "@renderer/services" -import type { IFuseOptions } from "fuse.js" -import Fuse from "fuse.js" import { produce } from "immer" import { merge, omit } from "lodash-es" @@ -293,18 +291,6 @@ class EntryActions { EntryService.deleteCollection(entryId) } } - - async createLocalSearch() { - const data = Object.values(get().flatMapEntries) - - const options: IFuseOptions = { - keys: ["entries.title", "entries.content", "entries.description"], - } - const index = Fuse.createIndex(options.keys!, data) - const fuse = new Fuse(data, options, index) - - return fuse - } } export const entryActions = new EntryActions() diff --git a/src/renderer/src/store/search/constants.ts b/src/renderer/src/store/search/constants.ts new file mode 100644 index 000000000..386a60b24 --- /dev/null +++ b/src/renderer/src/store/search/constants.ts @@ -0,0 +1,12 @@ +const SearchTypeBase = { + Feed: 1, + Entry: 1 << 1, + Subscription: 1 << 2, +} + +export const SearchType = { + ...SearchTypeBase, + All: Object.values(SearchTypeBase).reduce((acc, cur) => acc | cur, 0), +} + +export type SearchType = typeof SearchType[keyof typeof SearchType] diff --git a/src/renderer/src/store/search/helper.ts b/src/renderer/src/store/search/helper.ts new file mode 100644 index 000000000..c2a99ec76 --- /dev/null +++ b/src/renderer/src/store/search/helper.ts @@ -0,0 +1,3 @@ +import type { SearchInstance } from "./types" + +export const defineSearchInstance = (instance: SearchInstance) => instance diff --git a/src/renderer/src/store/search/index.ts b/src/renderer/src/store/search/index.ts new file mode 100644 index 000000000..223557edc --- /dev/null +++ b/src/renderer/src/store/search/index.ts @@ -0,0 +1,118 @@ +import type { EntryModel } from "@renderer/models" +import { + EntryRelatedKey, + EntryRelatedService, + EntryService, + FeedService, + SubscriptionService, +} from "@renderer/services" +import type { IFuseOptions } from "fuse.js" +import Fuse from "fuse.js" + +import type { SubscriptionPlainModel } from "../subscription" +import { createZustandStore } from "../utils/helper" +import { SearchType } from "./constants" +import { defineSearchInstance } from "./helper" +import type { SearchResult, SearchState } from "./types" + +const createState = (): SearchState => ({ + feeds: [], + entries: [], + subscriptions: [], + keyword: "", + searchType: SearchType.All, +}) +export const useSearchStore = + createZustandStore("search")(createState) + +const { getState: get, setState: set } = useSearchStore + +class SearchActions { + reset() { + set(createState) + } + + private createFuse(data: T[], keys: (keyof T)[]) { + const options: IFuseOptions = { + keys: keys as any, + } + const index = Fuse.createIndex(options.keys!, data) + return new Fuse(data, options, index) + } + + async createLocalDbSearch() { + const [entries, feeds, subscriptions, entryRelated] = await Promise.all([ + EntryService.findAll(), + FeedService.findAll(), + SubscriptionService.findAll(), + EntryRelatedService.findAll(EntryRelatedKey.FEED_ID), + ]) + + const entriesFuse = this.createFuse(entries, [ + "title", + "content", + "description", + ]) + const feedsFuse = this.createFuse(feeds, ["title", "description"]) + const subscriptionsFuse = this.createFuse(subscriptions, [ + "title", + "category", + ]) + + return defineSearchInstance({ + search(keyword: string) { + const type = get().searchType + const entries = + type & SearchType.Entry ? entriesFuse.search(keyword) : [] + const feeds = type & SearchType.Feed ? feedsFuse.search(keyword) : [] + + const subscriptions = + type & SearchType.Subscription ? + subscriptionsFuse.search(keyword) : + [] + + const processedEntries = [] as SearchResult< + EntryModel, + { feedId: string } + >[] + for (const entry of entries) { + const feedId = entryRelated[entry.item.id] + if (feedId) { + processedEntries.push({ item: entry.item, feedId }) + } + } + + const processedSubscriptions = [] as SearchResult< + SubscriptionPlainModel, + { feedId: string } + >[] + for (const subscription of subscriptions) { + const { feedId } = subscription.item + if (feedId) { + processedSubscriptions.push({ item: subscription.item, feedId }) + } + } + + set({ + keyword, + entries: processedEntries, + feeds, + subscriptions: processedSubscriptions, + searchType: type, + }) + + return get() + }, + }) + } + + setSearchType(type: SearchType) { + set({ searchType: type }) + } + + getCurrentKeyword() { + return get().keyword + } +} + +export const searchActions = new SearchActions() diff --git a/src/renderer/src/store/search/types.ts b/src/renderer/src/store/search/types.ts new file mode 100644 index 000000000..93ff7b00f --- /dev/null +++ b/src/renderer/src/store/search/types.ts @@ -0,0 +1,22 @@ +import type { EntryModel, FeedModel } from "@renderer/models" + +import type { SubscriptionPlainModel } from "../subscription" +import type { SearchType } from "./constants" + +// @ts-expect-error +export interface SearchResult + extends A { + item: T +} + +export interface SearchState { + feeds: SearchResult[] + entries: SearchResult[] + subscriptions: SearchResult[] + + keyword: string + searchType: SearchType +} +export interface SearchInstance { + search: (keyword: string) => SearchState +} diff --git a/src/renderer/src/store/unread.ts b/src/renderer/src/store/unread/index.ts similarity index 97% rename from src/renderer/src/store/unread.ts rename to src/renderer/src/store/unread/index.ts index dbbc5f994..5f37530dd 100644 --- a/src/renderer/src/store/unread.ts +++ b/src/renderer/src/store/unread/index.ts @@ -2,7 +2,7 @@ import { apiClient } from "@renderer/lib/api-fetch" import type { FeedViewType } from "@renderer/lib/enum" import { FeedUnreadService } from "@renderer/services" -import { createZustandStore } from "./utils/helper" +import { createZustandStore } from "../utils/helper" interface UnreadState { data: Record