405 lines
13 KiB
TypeScript
405 lines
13 KiB
TypeScript
import clsx from "clsx"
|
|
import { Command } from "cmdk"
|
|
import type { FC } from "react"
|
|
import * as React from "react"
|
|
import { memo, useMemo } from "react"
|
|
import { useTranslation } from "react-i18next"
|
|
|
|
import { setAppSearchOpen, useAppSearchOpen } from "~/atoms/app"
|
|
import { ExPromise } from "~/components/common/ExPromise"
|
|
import { LoadMoreIndicator } from "~/components/common/LoadMoreIndicator"
|
|
import { FeedIcon } from "~/components/feed-icon"
|
|
import { EmptyIcon } from "~/components/icons/empty"
|
|
import { Logo } from "~/components/icons/logo"
|
|
import { ScrollArea } from "~/components/ui/scroll-area"
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "~/components/ui/select"
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip"
|
|
import { ROUTE_ENTRY_PENDING } from "~/constants"
|
|
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
|
|
import { useI18n, useInputComposition } from "~/hooks/common"
|
|
import type { FeedViewType } from "~/lib/enum"
|
|
import { cn } from "~/lib/utils"
|
|
import { getFeedById } from "~/store/feed"
|
|
import { searchActions, useSearchStore } from "~/store/search"
|
|
import { SearchType } from "~/store/search/constants"
|
|
import type { SearchInstance } from "~/store/search/types"
|
|
import { getSubscriptionByFeedId } from "~/store/subscription"
|
|
import { useFeedUnreadStore } from "~/store/unread"
|
|
|
|
import styles from "./cmdk.module.css"
|
|
|
|
const SearchCmdKContext = React.createContext<Promise<SearchInstance> | null>(null)
|
|
export const SearchCmdK: React.FC = () => {
|
|
const { t } = useTranslation()
|
|
const open = useAppSearchOpen()
|
|
|
|
const [searchInstance, setSearchInstance] = React.useState(() =>
|
|
searchActions.createLocalDbSearch(),
|
|
)
|
|
React.useEffect(() => {
|
|
if (!open) return
|
|
|
|
window.posthog?.capture("search_open")
|
|
// Refresh data
|
|
setPage(0)
|
|
setSearchInstance(() => searchActions.createLocalDbSearch())
|
|
}, [open])
|
|
|
|
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 { onCompositionEnd, onCompositionStart, isCompositionRef } =
|
|
useInputComposition<HTMLInputElement>({})
|
|
const handleKeyDownToFocusInput: React.EventHandler<React.KeyboardEvent> = React.useCallback(
|
|
(e) => {
|
|
const $input = inputRef.current
|
|
|
|
if (e.key === "Escape" && !isCompositionRef.current) {
|
|
setAppSearchOpen(false)
|
|
return
|
|
}
|
|
|
|
if (e.key === "ArrowDown" || e.key === "ArrowUp") return
|
|
|
|
if (!e.ctrlKey && !e.metaKey && !e.altKey) {
|
|
$input?.focus()
|
|
}
|
|
},
|
|
[isCompositionRef],
|
|
)
|
|
const [isPending, startTransition] = React.useTransition()
|
|
const handleSearch = React.useCallback(
|
|
async (value: string) => {
|
|
const { search } = await searchInstance
|
|
setPage(0)
|
|
startTransition(() => {
|
|
search(value)
|
|
const $scrollView = scrollViewRef.current
|
|
if ($scrollView) {
|
|
$scrollView.scrollTop = 0
|
|
}
|
|
})
|
|
},
|
|
[searchInstance],
|
|
)
|
|
// Performance optimization
|
|
const [page, setPage] = React.useState(0)
|
|
const pageSize = 16
|
|
const renderedEntries = useMemo(() => entries.slice(0, (page + 1) * pageSize), [entries, page])
|
|
|
|
const renderedFeeds = useMemo(() => {
|
|
const delta = entries.length - renderedEntries.length
|
|
|
|
if (delta > pageSize) return []
|
|
|
|
const entriesTotalPage = Math.ceil(entries.length / pageSize)
|
|
const right = entriesTotalPage === page + 1 ? delta : pageSize * page + 1 - entries.length
|
|
|
|
return feeds.slice(0, right)
|
|
}, [entries.length, feeds, page, renderedEntries.length])
|
|
const totalCount = entries.length + feeds.length
|
|
const renderedTotalCount = renderedEntries.length + renderedFeeds.length
|
|
const loadMore = React.useCallback(() => {
|
|
const totalPage = Math.ceil((entries.length + feeds.length) / pageSize)
|
|
setPage((p) => Math.min(p + 1, totalPage))
|
|
}, [entries.length, feeds.length])
|
|
|
|
const canLoadMore = totalCount > renderedTotalCount && renderedTotalCount > 0
|
|
|
|
return (
|
|
<SearchCmdKContext.Provider value={searchInstance}>
|
|
<Command.Dialog
|
|
ref={dialogRef}
|
|
shouldFilter={false}
|
|
open={open}
|
|
onKeyDown={handleKeyDownToFocusInput}
|
|
onOpenChange={setAppSearchOpen}
|
|
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-xl dark:bg-neutral-900/90 md:rounded-xl",
|
|
"border-0 border-zinc-300 dark:border-zinc-700 md:border",
|
|
"absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2",
|
|
"z-10",
|
|
)}
|
|
>
|
|
<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() || t("search.placeholder")}
|
|
onValueChange={handleSearch}
|
|
onCompositionStart={onCompositionStart}
|
|
onCompositionEnd={onCompositionEnd}
|
|
/>
|
|
<div className={cn(styles["status-bar"], isPending && styles["loading"])} />
|
|
|
|
<ScrollArea.ScrollArea
|
|
ref={scrollViewRef}
|
|
viewportClassName="max-h-[50vh] [&>div]:!flex"
|
|
rootClassName="h-full px-5"
|
|
scrollbarClassName="mb-6"
|
|
>
|
|
<Command.List className="flex w-full min-w-0 flex-col">
|
|
<SearchPlaceholder />
|
|
|
|
{renderedEntries.length > 0 && (
|
|
<Command.Group
|
|
heading={
|
|
<SearchGroupHeading
|
|
icon="i-mgc-paper-cute-fi size-4"
|
|
title={t("search.group.entries")}
|
|
/>
|
|
}
|
|
className="flex w-full min-w-0 flex-col py-2"
|
|
>
|
|
{renderedEntries.map((entry) => {
|
|
const feed = getFeedById(entry.feedId)
|
|
return (
|
|
<SearchItem
|
|
key={`entry-${entry.item.id}-${entry.feedId}`}
|
|
view={feed?.id ? getSubscriptionByFeedId(feed.id)?.view : undefined}
|
|
title={entry.item.title!}
|
|
feedId={entry.feedId}
|
|
entryId={entry.item.id}
|
|
id={entry.item.id}
|
|
icon={feed?.type === "feed" ? feed?.siteUrl : undefined}
|
|
subtitle={feed?.title}
|
|
/>
|
|
)
|
|
})}
|
|
</Command.Group>
|
|
)}
|
|
{renderedFeeds.length > 0 && (
|
|
<Command.Group
|
|
heading={
|
|
<SearchGroupHeading
|
|
icon="i-mgc-rss-cute-fi size-4 text-accent"
|
|
title={t("search.group.feeds")}
|
|
/>
|
|
}
|
|
className="py-2"
|
|
>
|
|
{renderedFeeds.map((feed) => (
|
|
<SearchItem
|
|
key={`feed-${feed.item.id}`}
|
|
view={getSubscriptionByFeedId(feed.item.id!)?.view}
|
|
title={feed.item.title!}
|
|
feedId={feed.item.id!}
|
|
entryId={ROUTE_ENTRY_PENDING}
|
|
id={feed.item.id!}
|
|
icon={feed.item.type === "feed" ? feed.item.siteUrl : undefined}
|
|
subtitle={useFeedUnreadStore.getState().data[feed.item.id!]?.toString()}
|
|
/>
|
|
))}
|
|
</Command.Group>
|
|
)}
|
|
{canLoadMore && <LoadMoreIndicator className="center w-full" onLoading={loadMore} />}
|
|
</Command.List>
|
|
</ScrollArea.ScrollArea>
|
|
<SearchOptions />
|
|
<SearchResultCount count={totalCount} />
|
|
</Command.Dialog>
|
|
</SearchCmdKContext.Provider>
|
|
)
|
|
}
|
|
|
|
type SearchListType = {
|
|
title: string
|
|
subtitle?: Nullable<string>
|
|
feedId?: string
|
|
entryId?: string
|
|
icon?: Nullable<string>
|
|
id: string
|
|
view?: FeedViewType
|
|
}
|
|
|
|
const SearchItem = memo(function Item({
|
|
id,
|
|
title,
|
|
entryId,
|
|
feedId,
|
|
|
|
subtitle,
|
|
view,
|
|
}: {} & SearchListType) {
|
|
const navigateEntry = useNavigateEntry()
|
|
|
|
const feed = getFeedById(feedId!)
|
|
|
|
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-[""]`,
|
|
"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",
|
|
styles["content-visually"],
|
|
)}
|
|
key={`${id}-${feedId}-${entryId}`}
|
|
value={`${id}-${feedId}-${entryId}`}
|
|
onSelect={() => {
|
|
navigateEntry({
|
|
feedId: feedId!,
|
|
entryId,
|
|
view,
|
|
})
|
|
}}
|
|
>
|
|
<div className="relative z-10 flex w-full items-center justify-between px-1 py-2">
|
|
{feed && <FeedIcon className="mr-2 size-5 shrink-0 rounded" feed={feed} />}
|
|
<span className="block min-w-0 flex-1 shrink-0 truncate">{title}</span>
|
|
<span className="block min-w-0 shrink-0 grow-0 text-xs font-medium text-zinc-800 opacity-60 dark:text-slate-200/80">
|
|
{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 SearchResultCount: FC<{
|
|
count?: number
|
|
}> = ({ count }) => {
|
|
const t = useI18n()
|
|
const searchInstance = React.useContext(SearchCmdKContext)
|
|
const hasKeyword = useSearchStore((s) => !!s.keyword)
|
|
const searchType = useSearchStore((s) => s.searchType)
|
|
|
|
const recordCountPromise = useMemo(async () => {
|
|
let count = 0
|
|
const counts = await searchInstance?.then((s) => s.counts)
|
|
if (!counts) return 0
|
|
if (searchType & SearchType.Entry) {
|
|
count += counts.entries || 0
|
|
}
|
|
if (searchType & SearchType.Feed) {
|
|
count += counts.feeds || 0
|
|
}
|
|
if (searchType & SearchType.Subscription) {
|
|
count += counts.subscriptions || 0
|
|
}
|
|
return count
|
|
}, [searchInstance, searchType])
|
|
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<small className="center absolute bottom-3 right-3 shrink-0 gap-1 opacity-80">
|
|
{hasKeyword ? (
|
|
<span>
|
|
{count} {t.common("words.result", { count })}
|
|
</span>
|
|
) : (
|
|
<ExPromise promise={recordCountPromise}>
|
|
{(count) => (
|
|
<>
|
|
{count} {t.common("quantifier.piece")}
|
|
{t.common("words.local")}
|
|
{t.common("words.space")}
|
|
{t.common("words.record", { count })}
|
|
</>
|
|
)}
|
|
</ExPromise>
|
|
)}{" "}
|
|
{t("search.result_count_local_mode")}
|
|
<i className="i-mingcute-question-line" />
|
|
</small>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{t("search.tooltip.local_search")}</TooltipContent>
|
|
</Tooltip>
|
|
)
|
|
}
|
|
const SearchOptions: Component = memo(({ children }) => {
|
|
const { t } = useTranslation()
|
|
const searchType = useSearchStore((s) => s.searchType)
|
|
|
|
const searchInstance = React.useContext(SearchCmdKContext)
|
|
|
|
return (
|
|
<div className="absolute bottom-2 left-4 flex items-center gap-2 text-sm text-theme-foreground/80">
|
|
<span className="shrink-0">{t("search.options.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 position="item-aligned">
|
|
<SelectItem
|
|
className="hover:bg-theme-item-hover"
|
|
value={`${SearchType.All}`}
|
|
disabled={searchType === SearchType.All}
|
|
>
|
|
{t("search.options.all")}
|
|
</SelectItem>
|
|
<SelectItem
|
|
className="hover:bg-theme-item-hover"
|
|
value={`${SearchType.Entry}`}
|
|
disabled={searchType === SearchType.Entry}
|
|
>
|
|
{t("search.options.entries")}
|
|
</SelectItem>
|
|
<SelectItem
|
|
className="hover:bg-theme-item-hover"
|
|
value={`${SearchType.Feed}`}
|
|
disabled={searchType === SearchType.Feed}
|
|
>
|
|
{t("search.options.feeds")}
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
{children}
|
|
</div>
|
|
)
|
|
})
|
|
|
|
const SearchPlaceholder = () => {
|
|
const { t } = useTranslation()
|
|
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 />
|
|
{t("search.empty.no_results")}
|
|
</div>
|
|
) : (
|
|
<Logo className="size-12 opacity-80 grayscale" />
|
|
)}
|
|
</Command.Empty>
|
|
)
|
|
}
|