mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-12 12:20:23 +01:00
Move to TanStack Start from Next.js (#184)
This commit is contained in:
91
web/app/components/custom/ai-search.tsx
Normal file
91
web/app/components/custom/ai-search.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import * as React from "react"
|
||||
import * as smd from "streaming-markdown"
|
||||
|
||||
interface AiSearchProps {
|
||||
searchQuery: string
|
||||
}
|
||||
|
||||
const AiSearch: React.FC<AiSearchProps> = (props: { searchQuery: string }) => {
|
||||
const [error, setError] = React.useState<string>("")
|
||||
|
||||
const root_el = React.useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const [parser, md_el] = React.useMemo(() => {
|
||||
const md_el = document.createElement("div")
|
||||
const renderer = smd.default_renderer(md_el)
|
||||
const parser = smd.parser(renderer)
|
||||
return [parser, md_el]
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (root_el.current) {
|
||||
root_el.current.appendChild(md_el)
|
||||
}
|
||||
}, [md_el])
|
||||
|
||||
React.useEffect(() => {
|
||||
const question = props.searchQuery
|
||||
|
||||
fetchData()
|
||||
async function fetchData() {
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch("/api/search-stream", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ question: question }),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error fetching data:", error)
|
||||
setError("Error fetching data")
|
||||
return
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
console.error("Response has no body")
|
||||
setError("Response has no body")
|
||||
return
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
let done = false
|
||||
while (!done) {
|
||||
const res = await reader.read()
|
||||
|
||||
if (res.value) {
|
||||
const text = decoder.decode(res.value)
|
||||
smd.parser_write(parser, text)
|
||||
}
|
||||
|
||||
if (res.done) {
|
||||
smd.parser_end(parser)
|
||||
done = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [props.searchQuery, parser])
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-3xl flex-col items-center">
|
||||
<div className="w-full rounded-lg bg-inherit p-6 text-black dark:text-white">
|
||||
<div className="mb-6 rounded-lg bg-blue-700 p-4 text-white">
|
||||
<h2 className="text-lg font-medium">✨ This is what I have found:</h2>
|
||||
</div>
|
||||
<div
|
||||
className="rounded-xl bg-neutral-100 p-4 dark:bg-[#121212]"
|
||||
ref={root_el}
|
||||
></div>
|
||||
</div>
|
||||
<p className="text-md pb-5 font-semibold opacity-50">{error}</p>
|
||||
<button className="text-md rounded-2xl bg-neutral-300 px-6 py-3 font-semibold text-opacity-50 shadow-inner shadow-neutral-400/50 transition-colors hover:bg-neutral-700 dark:bg-neutral-800 dark:shadow-neutral-700/50">
|
||||
Ask Community
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AiSearch
|
||||
44
web/app/components/custom/column.tsx
Normal file
44
web/app/components/custom/column.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface ColumnWrapperProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
style?: { [key: string]: string }
|
||||
}
|
||||
|
||||
interface ColumnTextProps extends React.HTMLAttributes<HTMLSpanElement> {}
|
||||
|
||||
const ColumnWrapper = React.forwardRef<HTMLDivElement, ColumnWrapperProps>(
|
||||
({ children, className, style, ...props }, ref) => (
|
||||
<div
|
||||
className={cn("flex grow flex-row items-center justify-start", className)}
|
||||
style={{
|
||||
width: "var(--width)",
|
||||
minWidth: "var(--min-width, min-content)",
|
||||
maxWidth: "min(var(--width), var(--max-width))",
|
||||
flexBasis: "var(--width)",
|
||||
...style,
|
||||
}}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
)
|
||||
|
||||
ColumnWrapper.displayName = "ColumnWrapper"
|
||||
|
||||
const ColumnText = React.forwardRef<HTMLSpanElement, ColumnTextProps>(
|
||||
({ children, className, ...props }, ref) => (
|
||||
<span className={cn("text-left text-xs", className)} ref={ref} {...props}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
)
|
||||
|
||||
ColumnText.displayName = "ColumnText"
|
||||
|
||||
export const Column = {
|
||||
Wrapper: ColumnWrapper,
|
||||
Text: ColumnText,
|
||||
}
|
||||
58
web/app/components/custom/content-header.tsx
Normal file
58
web/app/components/custom/content-header.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import * as React from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useAtom } from "jotai"
|
||||
import { isCollapseAtom, toggleCollapseAtom } from "@/store/sidebar"
|
||||
import { useMedia } from "@/hooks/use-media"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
|
||||
type ContentHeaderProps = Omit<React.HTMLAttributes<HTMLDivElement>, "title">
|
||||
|
||||
export const ContentHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
ContentHeaderProps
|
||||
>(({ children, className, ...props }, ref) => {
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"flex min-h-10 min-w-0 shrink-0 items-center gap-3 pl-8 pr-6 transition-opacity max-lg:px-4",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</header>
|
||||
)
|
||||
})
|
||||
|
||||
ContentHeader.displayName = "ContentHeader"
|
||||
|
||||
export const SidebarToggleButton: React.FC = () => {
|
||||
const [isCollapse] = useAtom(isCollapseAtom)
|
||||
const [, toggle] = useAtom(toggleCollapseAtom)
|
||||
const isTablet = useMedia("(max-width: 1024px)")
|
||||
|
||||
if (!isCollapse && !isTablet) return null
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
toggle()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label="Menu"
|
||||
className="text-primary/60"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<LaIcon name="PanelLeft" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
57
web/app/components/custom/date-picker.tsx
Normal file
57
web/app/components/custom/date-picker.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import * as React from "react"
|
||||
import { format } from "date-fns"
|
||||
import { Calendar as CalendarIcon } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Calendar } from "@/components/ui/calendar"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
|
||||
interface DatePickerProps {
|
||||
date: Date | undefined
|
||||
onDateChange: (date: Date | undefined) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function DatePicker({ date, onDateChange, className }: DatePickerProps) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
|
||||
const selectDate = (selectedDate: Date | undefined) => {
|
||||
onDateChange(selectedDate)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
className={cn(
|
||||
"w-[240px] justify-start text-left font-normal",
|
||||
!date && "text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{date ? format(date, "PPP") : <span>Pick a date</span>}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-auto p-0"
|
||||
align="start"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={date}
|
||||
onSelect={selectDate}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
30
web/app/components/custom/la-icon.tsx
Normal file
30
web/app/components/custom/la-icon.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { icons } from "lucide-react"
|
||||
|
||||
export type IconProps = {
|
||||
name: keyof typeof icons
|
||||
className?: string
|
||||
strokeWidth?: number
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export const LaIcon = React.memo(
|
||||
({ name, className, size, strokeWidth, ...props }: IconProps) => {
|
||||
const IconComponent = icons[name]
|
||||
|
||||
if (!IconComponent) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<IconComponent
|
||||
className={cn(!size ? "size-4" : size, className)}
|
||||
strokeWidth={strokeWidth || 2}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
LaIcon.displayName = "LaIcon"
|
||||
137
web/app/components/custom/learning-state-selector.tsx
Normal file
137
web/app/components/custom/learning-state-selector.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import * as React from "react"
|
||||
import { useAtom } from "jotai"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
} from "@/components/ui/popover"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { LEARNING_STATES, LearningStateValue } from "@/lib/constants"
|
||||
import { linkLearningStateSelectorAtom } from "@/store/link"
|
||||
import {
|
||||
Command,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandItem,
|
||||
CommandGroup,
|
||||
} from "@/components/ui/command"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { icons } from "lucide-react"
|
||||
|
||||
interface LearningStateSelectorProps {
|
||||
showSearch?: boolean
|
||||
defaultLabel?: string
|
||||
searchPlaceholder?: string
|
||||
value?: string
|
||||
onChange: (value: LearningStateValue) => void
|
||||
className?: string
|
||||
defaultIcon?: keyof typeof icons
|
||||
}
|
||||
|
||||
export const LearningStateSelector: React.FC<LearningStateSelectorProps> = ({
|
||||
showSearch = true,
|
||||
defaultLabel = "State",
|
||||
searchPlaceholder = "Search state...",
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
defaultIcon,
|
||||
}) => {
|
||||
const [isLearningStateSelectorOpen, setIsLearningStateSelectorOpen] = useAtom(
|
||||
linkLearningStateSelectorAtom,
|
||||
)
|
||||
const selectedLearningState = React.useMemo(
|
||||
() => LEARNING_STATES.find((ls) => ls.value === value),
|
||||
[value],
|
||||
)
|
||||
|
||||
const handleSelect = (selectedValue: string) => {
|
||||
onChange(selectedValue as LearningStateValue)
|
||||
setIsLearningStateSelectorOpen(false)
|
||||
}
|
||||
|
||||
const iconName = selectedLearningState?.icon || defaultIcon
|
||||
const labelText = selectedLearningState?.label || defaultLabel
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={isLearningStateSelectorOpen}
|
||||
onOpenChange={setIsLearningStateSelectorOpen}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
role="combobox"
|
||||
variant="secondary"
|
||||
className={cn("gap-x-2 text-sm", className)}
|
||||
>
|
||||
{iconName && (
|
||||
<LaIcon
|
||||
name={iconName}
|
||||
className={cn(selectedLearningState?.className)}
|
||||
/>
|
||||
)}
|
||||
{labelText && (
|
||||
<span
|
||||
className={cn("truncate", selectedLearningState?.className || "")}
|
||||
>
|
||||
{labelText}
|
||||
</span>
|
||||
)}
|
||||
<LaIcon name="ChevronDown" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-52 rounded-lg p-0" side="bottom" align="end">
|
||||
<LearningStateSelectorContent
|
||||
showSearch={showSearch}
|
||||
searchPlaceholder={searchPlaceholder}
|
||||
value={value}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
interface LearningStateSelectorContentProps {
|
||||
showSearch: boolean
|
||||
searchPlaceholder: string
|
||||
value?: string
|
||||
onSelect: (value: string) => void
|
||||
}
|
||||
|
||||
export const LearningStateSelectorContent: React.FC<
|
||||
LearningStateSelectorContentProps
|
||||
> = ({ showSearch, searchPlaceholder, value, onSelect }) => {
|
||||
return (
|
||||
<Command>
|
||||
{showSearch && (
|
||||
<CommandInput placeholder={searchPlaceholder} className="h-9" />
|
||||
)}
|
||||
<CommandList>
|
||||
<ScrollArea>
|
||||
<CommandGroup>
|
||||
{LEARNING_STATES.map((ls) => (
|
||||
<CommandItem key={ls.value} value={ls.value} onSelect={onSelect}>
|
||||
{ls.icon && (
|
||||
<LaIcon name={ls.icon} className={cn("mr-2", ls.className)} />
|
||||
)}
|
||||
<span className={ls.className}>{ls.label}</span>
|
||||
<LaIcon
|
||||
name="Check"
|
||||
className={cn(
|
||||
"absolute right-3",
|
||||
ls.value === value ? "text-primary" : "text-transparent",
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</ScrollArea>
|
||||
</CommandList>
|
||||
</Command>
|
||||
)
|
||||
}
|
||||
32
web/app/components/custom/spinner.tsx
Normal file
32
web/app/components/custom/spinner.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface SpinnerProps extends React.SVGAttributes<SVGElement> {}
|
||||
|
||||
export const Spinner = React.forwardRef<SVGSVGElement, SpinnerProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<svg
|
||||
ref={ref}
|
||||
className={cn("h-4 w-4 animate-spin", className)}
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
)
|
||||
|
||||
Spinner.displayName = "Spinner"
|
||||
25
web/app/components/custom/textarea-autosize.tsx
Normal file
25
web/app/components/custom/textarea-autosize.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import * as React from "react"
|
||||
import BaseTextareaAutosize from "react-textarea-autosize"
|
||||
import { TextareaAutosizeProps as BaseTextareaAutosizeProps } from "react-textarea-autosize"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface TextareaProps extends Omit<BaseTextareaAutosizeProps, "ref"> {}
|
||||
|
||||
const TextareaAutosize = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<BaseTextareaAutosize
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[60px] w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
TextareaAutosize.displayName = "TextareaAutosize"
|
||||
|
||||
export { TextareaAutosize }
|
||||
208
web/app/components/custom/topic-selector.tsx
Normal file
208
web/app/components/custom/topic-selector.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import * as React from "react"
|
||||
import { atom, useAtom } from "jotai"
|
||||
import { useVirtualizer } from "@tanstack/react-virtual"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
} from "@/components/ui/popover"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import {
|
||||
Command,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandItem,
|
||||
CommandGroup,
|
||||
} from "@/components/ui/command"
|
||||
import { useCoState } from "@/lib/providers/jazz-provider"
|
||||
import { PublicGlobalGroup } from "@/lib/schema/master/public-group"
|
||||
import { ListOfTopics, Topic } from "@/lib/schema"
|
||||
import { JAZZ_GLOBAL_GROUP_ID } from "@/lib/constants"
|
||||
import { VariantProps } from "class-variance-authority"
|
||||
|
||||
interface TopicSelectorProps extends VariantProps<typeof buttonVariants> {
|
||||
showSearch?: boolean
|
||||
defaultLabel?: string
|
||||
searchPlaceholder?: string
|
||||
value?: string | null
|
||||
onChange?: (value: string) => void
|
||||
onTopicChange?: (value: Topic) => void
|
||||
className?: string
|
||||
renderSelectedText?: (value?: string | null) => React.ReactNode
|
||||
side?: "bottom" | "top" | "right" | "left"
|
||||
align?: "center" | "end" | "start"
|
||||
}
|
||||
|
||||
export const topicSelectorAtom = atom(false)
|
||||
|
||||
export const TopicSelector = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
TopicSelectorProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
showSearch = true,
|
||||
defaultLabel = "Select topic",
|
||||
searchPlaceholder = "Search topic...",
|
||||
value,
|
||||
onChange,
|
||||
onTopicChange,
|
||||
className,
|
||||
renderSelectedText,
|
||||
side = "bottom",
|
||||
align = "end",
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [isTopicSelectorOpen, setIsTopicSelectorOpen] =
|
||||
useAtom(topicSelectorAtom)
|
||||
const group = useCoState(PublicGlobalGroup, JAZZ_GLOBAL_GROUP_ID, {
|
||||
root: { topics: [] },
|
||||
})
|
||||
|
||||
const handleSelect = React.useCallback(
|
||||
(selectedTopicName: string, topic: Topic) => {
|
||||
onChange?.(selectedTopicName)
|
||||
onTopicChange?.(topic)
|
||||
setIsTopicSelectorOpen(false)
|
||||
},
|
||||
[onChange, setIsTopicSelectorOpen, onTopicChange],
|
||||
)
|
||||
|
||||
const displaySelectedText = React.useMemo(() => {
|
||||
if (renderSelectedText) {
|
||||
return renderSelectedText(value)
|
||||
}
|
||||
return <span className="truncate">{value || defaultLabel}</span>
|
||||
}, [value, defaultLabel, renderSelectedText])
|
||||
|
||||
return (
|
||||
<Popover open={isTopicSelectorOpen} onOpenChange={setIsTopicSelectorOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
ref={ref}
|
||||
size="sm"
|
||||
type="button"
|
||||
role="combobox"
|
||||
variant="secondary"
|
||||
className={cn("gap-x-2 text-sm", className)}
|
||||
{...props}
|
||||
>
|
||||
{displaySelectedText}
|
||||
<LaIcon name="ChevronDown" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-52 rounded-lg p-0"
|
||||
side={side}
|
||||
align={align}
|
||||
>
|
||||
{group?.root.topics && (
|
||||
<TopicSelectorContent
|
||||
showSearch={showSearch}
|
||||
searchPlaceholder={searchPlaceholder}
|
||||
value={value}
|
||||
onSelect={handleSelect}
|
||||
topics={group.root.topics}
|
||||
/>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
TopicSelector.displayName = "TopicSelector"
|
||||
|
||||
interface TopicSelectorContentProps
|
||||
extends Omit<TopicSelectorProps, "onChange" | "onTopicChange"> {
|
||||
onSelect: (value: string, topic: Topic) => void
|
||||
topics: ListOfTopics
|
||||
}
|
||||
|
||||
const TopicSelectorContent: React.FC<TopicSelectorContentProps> = React.memo(
|
||||
({ showSearch, searchPlaceholder, value, onSelect, topics }) => {
|
||||
const [search, setSearch] = React.useState("")
|
||||
const filteredTopics = React.useMemo(
|
||||
() =>
|
||||
topics.filter((topic) =>
|
||||
topic?.prettyName.toLowerCase().includes(search.toLowerCase()),
|
||||
),
|
||||
[topics, search],
|
||||
)
|
||||
|
||||
const parentRef = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: filteredTopics.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 35,
|
||||
overscan: 5,
|
||||
})
|
||||
|
||||
return (
|
||||
<Command>
|
||||
{showSearch && (
|
||||
<CommandInput
|
||||
placeholder={searchPlaceholder}
|
||||
className="h-9"
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
/>
|
||||
)}
|
||||
<CommandList>
|
||||
<div ref={parentRef} style={{ height: "200px", overflow: "auto" }}>
|
||||
<div
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
width: "100%",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<CommandGroup>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const topic = filteredTopics[virtualRow.index]
|
||||
return (
|
||||
topic && (
|
||||
<CommandItem
|
||||
key={virtualRow.key}
|
||||
value={topic.name}
|
||||
onSelect={(value) => onSelect(value, topic)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
<span>{topic.prettyName}</span>
|
||||
<LaIcon
|
||||
name="Check"
|
||||
className={cn(
|
||||
"absolute right-3",
|
||||
topic.name === value
|
||||
? "text-primary"
|
||||
: "text-transparent",
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
)
|
||||
)
|
||||
})}
|
||||
</CommandGroup>
|
||||
</div>
|
||||
</div>
|
||||
</CommandList>
|
||||
</Command>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
TopicSelectorContent.displayName = "TopicSelectorContent"
|
||||
|
||||
export default TopicSelector
|
||||
Reference in New Issue
Block a user