feat: search panel

Signed-off-by: Innei <i@innei.in>
This commit is contained in:
Innei 2024-07-15 16:08:38 +08:00
parent 68b40fd777
commit e9539cfb02
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
14 changed files with 497 additions and 62 deletions

View File

@ -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

View File

@ -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>
)
))

View File

@ -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}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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

View File

@ -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({

View File

@ -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)
}

View File

@ -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()

View File

@ -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]

View File

@ -0,0 +1,3 @@
import type { SearchInstance } from "./types"
export const defineSearchInstance = (instance: SearchInstance) => instance

View File

@ -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()

View File

@ -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
}

View File

@ -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>