160 lines
4.1 KiB
TypeScript
160 lines
4.1 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import type { ReactNode } from "react";
|
||
|
|
import {
|
||
|
|
CheckSquare,
|
||
|
|
FileText,
|
||
|
|
Folder,
|
||
|
|
FolderKanban,
|
||
|
|
LayoutDashboard,
|
||
|
|
PenLine,
|
||
|
|
} from "lucide-react";
|
||
|
|
|
||
|
|
import { Badge } from "@/components/ui/badge";
|
||
|
|
import { cn } from "@/lib/utils";
|
||
|
|
|
||
|
|
export type SearchResultItem = {
|
||
|
|
id: string;
|
||
|
|
type: string;
|
||
|
|
title: string;
|
||
|
|
status: string | null;
|
||
|
|
parentId: string | null;
|
||
|
|
workspaceId: string | null;
|
||
|
|
descriptionSnippet: string | null;
|
||
|
|
parentBreadcrumb: string | null;
|
||
|
|
};
|
||
|
|
|
||
|
|
const TYPE_STYLES: Record<
|
||
|
|
string,
|
||
|
|
{ Icon: typeof CheckSquare; className: string }
|
||
|
|
> = {
|
||
|
|
task: {
|
||
|
|
Icon: CheckSquare,
|
||
|
|
className:
|
||
|
|
"text-teal-600 dark:text-teal-400 bg-teal-500/12 border-teal-500/25",
|
||
|
|
},
|
||
|
|
document: {
|
||
|
|
Icon: FileText,
|
||
|
|
className:
|
||
|
|
"text-sky-600 dark:text-sky-400 bg-sky-500/12 border-sky-500/25",
|
||
|
|
},
|
||
|
|
project: {
|
||
|
|
Icon: FolderKanban,
|
||
|
|
className:
|
||
|
|
"text-amber-600 dark:text-amber-400 bg-amber-500/12 border-amber-500/25",
|
||
|
|
},
|
||
|
|
whiteboard: {
|
||
|
|
Icon: PenLine,
|
||
|
|
className:
|
||
|
|
"text-violet-600 dark:text-violet-400 bg-violet-500/12 border-violet-500/25",
|
||
|
|
},
|
||
|
|
group: {
|
||
|
|
Icon: Folder,
|
||
|
|
className:
|
||
|
|
"text-orange-600 dark:text-orange-400 bg-orange-500/12 border-orange-500/25",
|
||
|
|
},
|
||
|
|
workspace: {
|
||
|
|
Icon: LayoutDashboard,
|
||
|
|
className:
|
||
|
|
"text-emerald-600 dark:text-emerald-400 bg-emerald-500/12 border-emerald-500/25",
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
function escapeRegExp(s: string): string {
|
||
|
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||
|
|
}
|
||
|
|
|
||
|
|
export function highlightText(text: string, query: string): ReactNode {
|
||
|
|
const q = query.trim();
|
||
|
|
if (!q) return text;
|
||
|
|
const re = new RegExp(`(${escapeRegExp(q)})`, "gi");
|
||
|
|
const parts = text.split(re);
|
||
|
|
return parts.map((part, i) => {
|
||
|
|
if (part.toLowerCase() === q.toLowerCase()) {
|
||
|
|
return (
|
||
|
|
<mark
|
||
|
|
key={i}
|
||
|
|
className="rounded-sm bg-amber-200/90 px-0.5 font-medium text-foreground dark:bg-amber-500/35"
|
||
|
|
>
|
||
|
|
{part}
|
||
|
|
</mark>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
return <span key={i}>{part}</span>;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatStatus(status: string | null): string {
|
||
|
|
if (!status) return "";
|
||
|
|
return status.replace(/_/g, " ");
|
||
|
|
}
|
||
|
|
|
||
|
|
export function SearchResultRow({
|
||
|
|
object,
|
||
|
|
query,
|
||
|
|
onClick,
|
||
|
|
active,
|
||
|
|
onMouseEnter,
|
||
|
|
}: {
|
||
|
|
object: SearchResultItem;
|
||
|
|
query: string;
|
||
|
|
onClick: () => void;
|
||
|
|
active: boolean;
|
||
|
|
onMouseEnter?: () => void;
|
||
|
|
}) {
|
||
|
|
const style = TYPE_STYLES[object.type] ?? {
|
||
|
|
Icon: Folder,
|
||
|
|
className:
|
||
|
|
"text-muted-foreground bg-muted/80 border-border",
|
||
|
|
};
|
||
|
|
const Icon = style.Icon;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={onClick}
|
||
|
|
onMouseEnter={onMouseEnter}
|
||
|
|
className={cn(
|
||
|
|
"flex h-10 w-full min-w-0 items-center gap-2.5 rounded-lg border border-transparent px-2.5 text-left text-sm transition-colors",
|
||
|
|
active
|
||
|
|
? "border-border bg-muted/90 shadow-sm dark:bg-muted/50"
|
||
|
|
: "hover:bg-muted/60 dark:hover:bg-muted/40",
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
<span
|
||
|
|
className={cn(
|
||
|
|
"flex size-8 shrink-0 items-center justify-center rounded-md border",
|
||
|
|
style.className,
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
<Icon className="size-4" />
|
||
|
|
</span>
|
||
|
|
<span className="min-w-0 flex-1">
|
||
|
|
<span className="flex min-w-0 items-center gap-2">
|
||
|
|
<span className="truncate font-medium leading-tight text-foreground">
|
||
|
|
{highlightText(object.title, query)}
|
||
|
|
</span>
|
||
|
|
{object.status ? (
|
||
|
|
<Badge
|
||
|
|
variant="secondary"
|
||
|
|
className="h-5 shrink-0 px-1.5 text-[10px] font-medium capitalize"
|
||
|
|
>
|
||
|
|
{formatStatus(object.status)}
|
||
|
|
</Badge>
|
||
|
|
) : null}
|
||
|
|
</span>
|
||
|
|
{object.parentBreadcrumb ? (
|
||
|
|
<span className="mt-0.5 block truncate text-[11px] text-muted-foreground">
|
||
|
|
{object.parentBreadcrumb}
|
||
|
|
</span>
|
||
|
|
) : null}
|
||
|
|
{object.descriptionSnippet ? (
|
||
|
|
<span className="mt-0.5 line-clamp-1 text-[11px] leading-snug text-muted-foreground">
|
||
|
|
{highlightText(object.descriptionSnippet, query)}
|
||
|
|
</span>
|
||
|
|
) : null}
|
||
|
|
</span>
|
||
|
|
</button>
|
||
|
|
);
|
||
|
|
}
|