parent
68b40fd777
commit
e9539cfb02
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#fff" fill-opacity=".01" d="M24 0v24H0V0z"/><path fill="#10161F" fill-rule="evenodd" d="M11.427 2.5h1.146c1.824 0 3.293 0 4.45.155 1.2.162 2.21.507 3.012 1.31.803.802 1.148 1.813 1.31 3.013.155 1.156.155 2.625.155 4.449v1.146c0 1.824 0 3.293-.155 4.45-.162 1.2-.507 2.21-1.31 3.012-.802.803-1.812 1.148-3.013 1.31-1.156.155-2.625.155-4.449.155h-1.146c-1.824 0-3.293 0-4.45-.155-1.2-.162-2.21-.507-3.013-1.31-.802-.802-1.147-1.812-1.309-3.013-.155-1.156-.155-2.625-.155-4.449v-1.146c0-1.824 0-3.293.155-4.45.162-1.2.507-2.21 1.31-3.013.802-.802 1.813-1.147 3.013-1.309C8.134 2.5 9.603 2.5 11.427 2.5M8.5 9c-.146 0-.29.005-.434.014a1 1 0 1 1-.132-1.995 8.5 8.5 0 0 1 9.047 9.047 1 1 0 1 1-1.995-.132A6.5 6.5 0 0 0 8.5 9M7 11.5a1 1 0 0 1 1-1 5.5 5.5 0 0 1 5.5 5.5 1 1 0 1 1-2 0A3.5 3.5 0 0 0 8 12.5a1 1 0 0 1-1-1m0 4a1.5 1.5 0 1 1 3 0 1.5 1.5 0 0 1-3 0" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 965 B |
|
|
@ -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) => (
|
||||
<Root className={rootClassName}>
|
||||
<Viewport onWheel={stopPropagation} className={viewportClassName}>
|
||||
<Viewport ref={ref} onWheel={stopPropagation} className={viewportClassName}>
|
||||
{children}
|
||||
</Viewport>
|
||||
<Scrollbar
|
||||
className={scrollbarClassName}
|
||||
/>
|
||||
<Scrollbar className={scrollbarClassName} />
|
||||
</Root>
|
||||
)
|
||||
))
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ const SelectTrigger = React.forwardRef<
|
|||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<i className="i-mingcute-down-line size-4 opacity-50" />
|
||||
<i className="i-mingcute-down-line ml-2 size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
|
|
@ -119,7 +119,7 @@ const SelectItem = React.forwardRef<
|
|||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-theme-item-active focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
"pointer-events-auto relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-theme-item-active focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -15,13 +15,12 @@ import { useWheel } from "@use-gesture/react"
|
|||
import type { MotionValue } from "framer-motion"
|
||||
import { m, useSpring } from "framer-motion"
|
||||
import { Lethargy } from "lethargy"
|
||||
import type { PropsWithChildren } from "react"
|
||||
import { useCallback, useLayoutEffect, useRef } from "react"
|
||||
import { isHotkeyPressed, useHotkeys } from "react-hotkeys-hook"
|
||||
import { Link } from "react-router-dom"
|
||||
|
||||
import { Vibrancy } from "../../components/ui/background"
|
||||
import { NetworkStatusIndicator } from "../app/NetworkStatusIndicator"
|
||||
import { AutoUpdater } from "./auto-updater"
|
||||
import { FeedList } from "./list"
|
||||
|
||||
const lethargy = new Lethargy()
|
||||
|
|
@ -41,7 +40,7 @@ const useBackHome = (active: number) => {
|
|||
[active, navigate],
|
||||
)
|
||||
}
|
||||
export function FeedColumn() {
|
||||
export function FeedColumn({ children }: PropsWithChildren) {
|
||||
const carouselRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [active, setActive_] = useSidebarActiveView()
|
||||
|
|
@ -195,14 +194,9 @@ export function FeedColumn() {
|
|||
))}
|
||||
</SwipeWrapper>
|
||||
</div>
|
||||
{APP_VERSION?.[0] === "0" && (
|
||||
<div className="pointer-events-none absolute bottom-3 w-full text-center text-xs opacity-20">
|
||||
Early Access
|
||||
</div>
|
||||
)}
|
||||
<AutoUpdater />
|
||||
|
||||
<NetworkStatusIndicator />
|
||||
{children}
|
||||
|
||||
</Vibrancy>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Promise<SearchInstance> | 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<HTMLInputElement>(null)
|
||||
const dialogRef = React.useRef<HTMLDivElement>(null)
|
||||
const scrollViewRef = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
const $input = inputRef.current
|
||||
if (open && $input) {
|
||||
$input.focus()
|
||||
}
|
||||
}, [open])
|
||||
const handleKeyDownToFocusInput: React.EventHandler<React.KeyboardEvent> =
|
||||
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 (
|
||||
<SearchCmdKContext.Provider value={searchInstance}>
|
||||
<Command.Dialog
|
||||
ref={dialogRef}
|
||||
shouldFilter={false}
|
||||
open={open}
|
||||
onKeyDown={handleKeyDownToFocusInput}
|
||||
onOpenChange={setOpen}
|
||||
className={cn(
|
||||
"h-[600px] max-h-[80vh] w-[800px] max-w-[100vw] rounded-none md:h-screen md:max-h-[60vh] md:max-w-[80vw]",
|
||||
"flex min-h-[50vh] flex-col bg-zinc-50/85 shadow-2xl backdrop-blur-md dark:bg-neutral-900/80 md:rounded-xl",
|
||||
"border-0 border-zinc-200 dark:border-zinc-800 md:border",
|
||||
"absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2",
|
||||
)}
|
||||
>
|
||||
<Command.Input
|
||||
className="w-full shrink-0 border-b border-zinc-200 bg-transparent p-4 px-5 text-lg leading-4 dark:border-neutral-700"
|
||||
ref={inputRef}
|
||||
placeholder={searchActions.getCurrentKeyword()}
|
||||
onValueChange={async (value) => {
|
||||
const { search } = await searchInstance
|
||||
search(value)
|
||||
const $scrollView = scrollViewRef.current
|
||||
if ($scrollView) {
|
||||
$scrollView.scrollTop = 0
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<ScrollArea.ScrollArea
|
||||
ref={scrollViewRef}
|
||||
viewportClassName="max-h-[50vh] px-5 [&>div]:!flex"
|
||||
rootClassName="h-full"
|
||||
>
|
||||
<Command.List className="flex w-full min-w-0 flex-col">
|
||||
<SearchPlaceholder />
|
||||
|
||||
{entries.length > 0 && (
|
||||
<Command.Group
|
||||
heading={(
|
||||
<SearchGroupHeading
|
||||
icon="i-mgc-paper-cute-fi size-4"
|
||||
title="Entries"
|
||||
/>
|
||||
)}
|
||||
className="flex w-full min-w-0 flex-col py-2"
|
||||
>
|
||||
{entries.map((entry, index) => {
|
||||
const feed = getFeedById(entry.feedId)
|
||||
return (
|
||||
<SearchItem
|
||||
key={entry.item.id}
|
||||
title={entry.item.title!}
|
||||
feedId={entry.feedId}
|
||||
entryId={entry.item.id}
|
||||
id={entry.item.id}
|
||||
index={index}
|
||||
icon={feed?.siteUrl}
|
||||
subtitle={feed?.title}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Command.Group>
|
||||
)}
|
||||
{feeds.length > 0 && (
|
||||
<Command.Group
|
||||
heading={(
|
||||
<SearchGroupHeading
|
||||
icon="i-mgc-rss-cute-fi size-4 text-theme-accent"
|
||||
title="Feeds"
|
||||
/>
|
||||
)}
|
||||
className="py-2"
|
||||
>
|
||||
{feeds.map((feed, index) => (
|
||||
<SearchItem
|
||||
key={feed.item.id}
|
||||
title={feed.item.title!}
|
||||
feedId={feed.item.id!}
|
||||
entryId={ROUTE_ENTRY_PENDING}
|
||||
id={feed.item.id!}
|
||||
index={entries.length + index}
|
||||
icon={feed.item.siteUrl}
|
||||
subtitle={useFeedUnreadStore.getState().data[feed.item.id!]?.toString()}
|
||||
/>
|
||||
))}
|
||||
</Command.Group>
|
||||
)}
|
||||
</Command.List>
|
||||
</ScrollArea.ScrollArea>
|
||||
<SearchOptions />
|
||||
</Command.Dialog>
|
||||
</SearchCmdKContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
type SearchListType = {
|
||||
title: string
|
||||
subtitle?: Nullable<string>
|
||||
feedId?: string
|
||||
entryId?: string
|
||||
icon?: Nullable<string>
|
||||
id: string
|
||||
}
|
||||
|
||||
const SearchItem = memo(function Item({
|
||||
index,
|
||||
...item
|
||||
}: {
|
||||
index: number
|
||||
} & SearchListType) {
|
||||
const navigateEntry = useNavigateEntry()
|
||||
return (
|
||||
<Command.Item
|
||||
className={clsx(
|
||||
"relative flex w-full justify-between px-1 text-[0.9rem]",
|
||||
"before:absolute before:inset-0 before:rounded-md before:content-auto",
|
||||
"before:z-0 hover:before:bg-zinc-200/60 dark:hover:before:bg-zinc-800/80",
|
||||
"data-[selected=true]:before:bg-zinc-200/60 data-[selected=true]:dark:before:bg-zinc-800/80",
|
||||
"min-w-0 max-w-full",
|
||||
)}
|
||||
key={item.id}
|
||||
onSelect={() => {
|
||||
navigateEntry({
|
||||
feedId: item.feedId!,
|
||||
entryId: item.entryId,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<div className="relative z-10 flex w-full items-center justify-between px-1 py-2">
|
||||
{item.icon && (
|
||||
<SiteIcon className="mr-2 size-5 shrink-0" url={item.icon} />
|
||||
)}
|
||||
<span className="block min-w-0 flex-1 shrink-0 truncate">
|
||||
{item.title}
|
||||
</span>
|
||||
<span className="block min-w-0 shrink-0 grow-0 text-xs font-medium text-zinc-800 opacity-80 dark:text-slate-200/80">
|
||||
{item.subtitle}
|
||||
</span>
|
||||
</div>
|
||||
</Command.Item>
|
||||
)
|
||||
})
|
||||
|
||||
const SearchGroupHeading: FC<{ icon: string, title: string }> = ({
|
||||
icon,
|
||||
title,
|
||||
}) => (
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<i className={icon} />
|
||||
<span className="text-sm font-semibold">{title}</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
const SearchOptions = () => {
|
||||
const searchType = useSearchStore((s) => s.searchType)
|
||||
|
||||
const searchInstance = React.useContext(SearchCmdKContext)
|
||||
const hasKeyword = useSearchStore((s) => !!s.keyword)
|
||||
return (
|
||||
<div className="absolute bottom-2 left-4 flex items-center gap-2 text-sm">
|
||||
<span className="shrink-0">Search Type</span>
|
||||
|
||||
<Select
|
||||
onValueChange={async (value) => {
|
||||
searchActions.setSearchType(+value as SearchType)
|
||||
|
||||
if (searchInstance) {
|
||||
const { search } = await searchInstance
|
||||
search(searchActions.getCurrentKeyword())
|
||||
}
|
||||
}}
|
||||
value={`${searchType}`}
|
||||
>
|
||||
<SelectTrigger size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
className="hover:bg-theme-item-hover"
|
||||
value={`${SearchType.All}`}
|
||||
disabled={searchType === SearchType.All}
|
||||
>
|
||||
All
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
className="hover:bg-theme-item-hover"
|
||||
value={`${SearchType.Entry}`}
|
||||
disabled={searchType === SearchType.Entry}
|
||||
>
|
||||
Entries
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
className="hover:bg-theme-item-hover"
|
||||
value={`${SearchType.Feed}`}
|
||||
disabled={searchType === SearchType.Feed}
|
||||
>
|
||||
Feeds
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{hasKeyword && (
|
||||
<small className="shrink-0 opacity-80">
|
||||
This search run on local database, the result may not be up-to-date.
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SearchPlaceholder = () => {
|
||||
const hasKeyword = useSearchStore((s) => !!s.keyword)
|
||||
return (
|
||||
<Command.Empty className="center absolute inset-0">
|
||||
{hasKeyword ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 opacity-80">
|
||||
<EmptyIcon />
|
||||
No results found.
|
||||
</div>
|
||||
) : (
|
||||
<Logo className="size-12 opacity-80 grayscale" />
|
||||
)}
|
||||
</Command.Empty>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="flex h-full" onContextMenu={preventDefault}>
|
||||
<div className="w-64 shrink-0 border-r">
|
||||
<FeedColumn />
|
||||
<FeedColumn>
|
||||
{APP_VERSION?.[0] === "0" && (
|
||||
<div className="pointer-events-none absolute bottom-3 w-full text-center text-xs opacity-20">
|
||||
Early Access
|
||||
</div>
|
||||
)}
|
||||
<AutoUpdater />
|
||||
|
||||
<NetworkStatusIndicator />
|
||||
</FeedColumn>
|
||||
</div>
|
||||
{/* NOTE: tabIndex for main element can get by `document.activeElement` */}
|
||||
<main
|
||||
|
|
@ -25,6 +37,8 @@ export function Component() {
|
|||
>
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
<SearchCmdK />
|
||||
{isAuthFail && !user && (
|
||||
<RootPortal>
|
||||
<DeclarativeModal
|
||||
|
|
|
|||
|
|
@ -11,10 +11,16 @@ type Model = {
|
|||
id: EntryRelatedKey
|
||||
data: Record<string, any>
|
||||
}
|
||||
|
||||
type IdToIdRecord = Record<string, string>
|
||||
type IdToBooleanRecord = Record<string, boolean>
|
||||
type IdToAnyObjectRecord = Record<string, Record<string, any>>
|
||||
class ServiceStatic {
|
||||
async findAll(
|
||||
type: EntryRelatedKey,
|
||||
): Promise<Record<string, any>> {
|
||||
async findAll(type: EntryRelatedKey.FEED_ID): Promise<IdToIdRecord>
|
||||
async findAll(type: EntryRelatedKey.READ): Promise<IdToBooleanRecord>
|
||||
async findAll(type: EntryRelatedKey.COLLECTION): Promise<IdToAnyObjectRecord>
|
||||
|
||||
async findAll(type: EntryRelatedKey): Promise<Record<string, any>> {
|
||||
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<string, any>) {
|
||||
async upsert(
|
||||
type: EntryRelatedKey.READ,
|
||||
data: IdToBooleanRecord
|
||||
): Promise<void>
|
||||
async upsert(
|
||||
type: EntryRelatedKey.FEED_ID,
|
||||
data: IdToIdRecord
|
||||
): Promise<void>
|
||||
async upsert(
|
||||
type: EntryRelatedKey.COLLECTION,
|
||||
data: IdToAnyObjectRecord
|
||||
): Promise<void>
|
||||
async upsert(type: any, data: Record<string, any>) {
|
||||
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({
|
||||
|
|
|
|||
|
|
@ -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<EntryModel> {
|
|||
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<string, boolean>) {
|
||||
return EntryRelatedService.upsert(EntryRelatedKey.READ, record)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<FlatEntryModel> = {
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import type { SearchInstance } from "./types"
|
||||
|
||||
export const defineSearchInstance = (instance: SearchInstance) => instance
|
||||
|
|
@ -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<SearchState>("search")(createState)
|
||||
|
||||
const { getState: get, setState: set } = useSearchStore
|
||||
|
||||
class SearchActions {
|
||||
reset() {
|
||||
set(createState)
|
||||
}
|
||||
|
||||
private createFuse<T extends object>(data: T[], keys: (keyof T)[]) {
|
||||
const options: IFuseOptions<T> = {
|
||||
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()
|
||||
|
|
@ -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<T extends object, A extends object = object>
|
||||
extends A {
|
||||
item: T
|
||||
}
|
||||
|
||||
export interface SearchState {
|
||||
feeds: SearchResult<FeedModel>[]
|
||||
entries: SearchResult<EntryModel, { feedId: string }>[]
|
||||
subscriptions: SearchResult<SubscriptionPlainModel, { feedId: string }>[]
|
||||
|
||||
keyword: string
|
||||
searchType: SearchType
|
||||
}
|
||||
export interface SearchInstance {
|
||||
search: (keyword: string) => SearchState
|
||||
}
|
||||
|
|
@ -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<string, number>
|
||||
Loading…
Reference in New Issue