Files
History-user/src/app/editor/[id]/EditorSearchResults.tsx
T

282 lines
10 KiB
TypeScript

"use client";
import type { CSSProperties, ReactNode } from "react";
import type { Entity } from "@/uhm/api/entities";
import type { EntityGeometriesSearchItem, EntityGeometrySearchGeo } from "@/uhm/api/geometries";
import type { Wiki } from "@/uhm/api/wikis";
import UnifiedSearchBar, { type UnifiedSearchKind } from "@/uhm/components/ui/UnifiedSearchBar";
type EditorSearchResultsProps = {
searchKind: UnifiedSearchKind;
onSearchKindChange: (kind: UnifiedSearchKind) => void;
searchQuery: string;
onSearchQueryChange: (query: string) => void;
onLocalSearchQueryChange: (query: string) => void;
searchQueryDraft: string;
entitySearchResults: Entity[];
isEntitySearchLoading: boolean;
onAddEntityRefToProject: (entity: Entity) => void;
wikiSearchResults: Wiki[];
isWikiSearching: boolean;
onAddWikiRefToProject: (wiki: Wiki) => void;
geoSearchResults: EntityGeometriesSearchItem[];
isGeoSearching: boolean;
onImportGeoFromSearch: (
entityItem: EntityGeometriesSearchItem,
geo: EntityGeometrySearchGeo
) => void;
};
export function EditorSearchResults({
searchKind,
onSearchKindChange,
searchQuery,
onSearchQueryChange,
onLocalSearchQueryChange,
searchQueryDraft,
entitySearchResults,
isEntitySearchLoading,
onAddEntityRefToProject,
wikiSearchResults,
isWikiSearching,
onAddWikiRefToProject,
geoSearchResults,
isGeoSearching,
onImportGeoFromSearch,
}: EditorSearchResultsProps) {
// Draft query quyết định có render kết quả hay không; query chính đã debounce ở page.
const hasQuery = searchQueryDraft.trim().length > 0;
return (
<>
<UnifiedSearchBar
kind={searchKind}
onKindChange={onSearchKindChange}
query={searchQuery}
onQueryChange={onSearchQueryChange}
onLocalQueryChange={onLocalSearchQueryChange}
/>
{searchKind === "entity" && hasQuery ? (
<SearchBox
title="Entity Results"
status={isEntitySearchLoading ? "Searching..." : `${entitySearchResults.length} results`}
>
{entitySearchResults.slice(0, 8).map((entity) => (
<ResultRow
key={entity.id}
title={entity.name}
subtitle={entity.id}
actionLabel="Add"
actionTitle="Add entity ref to project snapshot"
onAction={() => onAddEntityRefToProject(entity)}
/>
))}
{!isEntitySearchLoading && entitySearchResults.length === 0 ? <EmptyResult /> : null}
</SearchBox>
) : null}
{searchKind === "wiki" && hasQuery ? (
<SearchBox
title="Wiki Results"
status={isWikiSearching ? "Searching..." : `${wikiSearchResults.length} results`}
>
{wikiSearchResults.slice(0, 8).map((wiki) => (
<ResultRow
key={wiki.id}
title={(wiki.title || "").trim() || "Untitled wiki"}
subtitle={wiki.id}
actionLabel="Add"
actionTitle="Add wiki ref to project snapshot"
onAction={() => onAddWikiRefToProject(wiki)}
/>
))}
{!isWikiSearching && wikiSearchResults.length === 0 ? <EmptyResult /> : null}
</SearchBox>
) : null}
{searchKind === "geo" && hasQuery ? (
<SearchBox
title="Geo Results"
status={isGeoSearching ? "Searching..." : `${geoSearchResults.length} entities`}
>
{geoSearchResults.slice(0, 6).map((item) => (
<GeoResultGroup
key={item.entity_id}
item={item}
onImportGeoFromSearch={onImportGeoFromSearch}
/>
))}
{!isGeoSearching && geoSearchResults.length === 0 ? <EmptyResult /> : null}
</SearchBox>
) : null}
</>
);
}
function SearchBox({
title,
status,
children,
}: {
title: string;
status: string;
children: ReactNode;
}) {
return (
<div style={{ padding: 10, background: "#0b1220", borderRadius: 8, border: "1px solid #1f2937" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
<div style={{ fontWeight: 700, fontSize: 13, color: "white" }}>{title}</div>
<div style={{ fontSize: 12, color: "#94a3b8" }}>{status}</div>
</div>
<div style={{ marginTop: 8, display: "grid", gap: 6 }}>{children}</div>
</div>
);
}
function ResultRow({
title,
subtitle,
actionLabel,
actionTitle,
onAction,
}: {
title: string;
subtitle: string;
actionLabel: string;
actionTitle: string;
onAction: () => void;
}) {
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: 8,
borderRadius: 6,
border: "1px solid #1f2937",
background: "transparent",
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: "#e5e7eb", fontSize: 12, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{title}
</div>
<div style={{ color: "#94a3b8", fontSize: 11, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{subtitle}
</div>
</div>
<button
type="button"
onClick={onAction}
style={actionButtonStyle}
title={actionTitle}
>
{actionLabel}
</button>
</div>
);
}
function GeoResultGroup({
item,
onImportGeoFromSearch,
}: {
item: EntityGeometriesSearchItem;
onImportGeoFromSearch: (
entityItem: EntityGeometriesSearchItem,
geo: EntityGeometrySearchGeo
) => void;
}) {
const geometries = Array.isArray(item.geometries) ? item.geometries : [];
return (
<div
style={{
padding: 8,
borderRadius: 6,
border: "1px solid #1f2937",
background: "transparent",
display: "grid",
gap: 6,
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
<div style={{ minWidth: 0 }}>
<div style={{ color: "#e5e7eb", fontSize: 12, fontWeight: 700, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{item.name?.trim() || item.entity_id}
</div>
<div style={{ color: "#94a3b8", fontSize: 11, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{item.entity_id}
</div>
</div>
<div style={{ fontSize: 12, color: "#94a3b8", flex: "0 0 auto" }}>
{geometries.length} geos
</div>
</div>
{item.description?.trim() ? (
<div style={{ color: "#cbd5e1", fontSize: 12, lineHeight: 1.35 }}>
{item.description.trim()}
</div>
) : null}
{geometries.length ? (
<div style={{ display: "grid", gap: 6, maxHeight: 200, overflowY: "auto", paddingRight: 4 }}>
{geometries.map((geo) => (
<div
key={geo.id}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
padding: 8,
borderRadius: 6,
border: "1px solid #243244",
background: "#0f172a",
}}
>
<div style={{ minWidth: 0 }}>
<div style={{ color: "#e5e7eb", fontSize: 12, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
#{geo.id}
</div>
<div style={{ color: "#94a3b8", fontSize: 11 }}>
type: {geo.type || "unknown"}{" "}
{geo.time_start != null || geo.time_end != null
? `| time: ${geo.time_start ?? "?"} -> ${geo.time_end ?? "?"}`
: ""}
</div>
</div>
<button
type="button"
onClick={() => onImportGeoFromSearch(item, geo)}
style={{ ...actionButtonStyle, flex: "0 0 auto" }}
title="Import geometry into current editor draft"
>
Import
</button>
</div>
))}
</div>
) : (
<div style={{ fontSize: 12, color: "#94a3b8" }}>No geometry linked.</div>
)}
</div>
);
}
function EmptyResult() {
return <div style={{ fontSize: 12, color: "#94a3b8" }}>No results.</div>;
}
const actionButtonStyle: CSSProperties = {
border: "none",
background: "#111827",
color: "#93c5fd",
cursor: "pointer",
borderRadius: 6,
padding: "6px 8px",
fontSize: 12,
fontWeight: 700,
};