mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 18:19:05 +02:00
Narrow search column, add row spacing, render inline image previews (PAP-3468)
Tightens the /search results layout to a Google-style 680px-wide column centered in the page, swaps the divider lines for `gap-y-1` + bumped row padding so each result has visible breathing room, and renders an inline image preview when a result references one. Image previews: extracted server-side from the first markdown `\` in description / commentSnippet / documentSnippet and surfaced on `CompanySearchResult.previewImageUrl`. The row renders an 88×88 lazy thumbnail when present. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
1 parent
5d92571313
commit
f67f60d662
5 files changed
+53
-22
No files matched your search
@@ -41,6 +41,7 @@ export interface CompanySearchResult {
|
||||
snippets: CompanySearchSnippet[];
|
||||
issue?: CompanySearchIssueSummary;
|
||||
updatedAt: string | null;
|
||||
previewImageUrl: string | null;
|
||||
}
|
||||
|
||||
export interface CompanySearchResponse {
|
||||
|
||||
@@ -109,6 +109,14 @@ function plainText(value: string | null | undefined) {
|
||||
.trim();
|
||||
}
|
||||
|
||||
const MARKDOWN_IMAGE_PATTERN = /!\[[^\]]*\]\(\s*([^)\s]+)(?:\s+"[^"]*")?\s*\)/;
|
||||
|
||||
function extractFirstImageUrl(value: string | null | undefined): string | null {
|
||||
if (!value) return null;
|
||||
const match = MARKDOWN_IMAGE_PATTERN.exec(value);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
function findFirstMatchIndex(value: string, terms: string[]) {
|
||||
const lower = value.toLowerCase();
|
||||
let best = -1;
|
||||
@@ -247,6 +255,10 @@ function issueResult(row: IssueSearchRow, prefix: string, normalizedQuery: strin
|
||||
projectId: row.projectId,
|
||||
updatedAt: iso(row.updatedAt)!,
|
||||
};
|
||||
const previewImageUrl =
|
||||
extractFirstImageUrl(row.description) ??
|
||||
extractFirstImageUrl(row.commentSnippet) ??
|
||||
extractFirstImageUrl(row.documentSnippet);
|
||||
return {
|
||||
id: row.id,
|
||||
type: "issue",
|
||||
@@ -259,6 +271,7 @@ function issueResult(row: IssueSearchRow, prefix: string, normalizedQuery: strin
|
||||
snippets,
|
||||
issue,
|
||||
updatedAt: issue.updatedAt,
|
||||
previewImageUrl,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -643,6 +656,7 @@ export function companySearchService(db: Db) {
|
||||
snippet: snippet?.text ?? null,
|
||||
snippets: snippet ? [snippet] : [],
|
||||
updatedAt: iso(row.updatedAt),
|
||||
previewImageUrl: null,
|
||||
};
|
||||
}),
|
||||
...(projectRows as SimpleSearchRow[]).map((row) => {
|
||||
@@ -659,6 +673,7 @@ export function companySearchService(db: Db) {
|
||||
snippet: snippet?.text ?? null,
|
||||
snippets: snippet ? [snippet] : [],
|
||||
updatedAt: iso(row.updatedAt),
|
||||
previewImageUrl: null,
|
||||
};
|
||||
}),
|
||||
].sort((left, right) => {
|
||||
|
||||
@@ -51,7 +51,7 @@ export interface SearchResultRowProps {
|
||||
}
|
||||
|
||||
const ROW_BASE =
|
||||
"group flex items-start gap-3 px-4 transition-colors no-underline text-inherit hover:bg-muted/40";
|
||||
"group flex items-start gap-3 rounded-md px-3 transition-colors no-underline text-inherit hover:bg-muted/40";
|
||||
|
||||
function SearchResultRowImpl({
|
||||
result,
|
||||
@@ -63,7 +63,7 @@ function SearchResultRowImpl({
|
||||
return (
|
||||
<Link
|
||||
to={result.href}
|
||||
className={cn(ROW_BASE, "py-2.5", isActive && "bg-muted/40", className)}
|
||||
className={cn(ROW_BASE, "py-3", isActive && "bg-muted/40", className)}
|
||||
data-result-type="agent"
|
||||
>
|
||||
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
@@ -90,7 +90,7 @@ function SearchResultRowImpl({
|
||||
return (
|
||||
<Link
|
||||
to={result.href}
|
||||
className={cn(ROW_BASE, "py-2.5", isActive && "bg-muted/40", className)}
|
||||
className={cn(ROW_BASE, "py-3", isActive && "bg-muted/40", className)}
|
||||
data-result-type="project"
|
||||
>
|
||||
<Hexagon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
@@ -117,12 +117,13 @@ function SearchResultRowImpl({
|
||||
const updated = formatRelativeTime(result.updatedAt ?? issue.updatedAt);
|
||||
const titleHighlights = result.snippets.find((snippet) => snippet.field === "title")?.highlights;
|
||||
const bodySnippets = result.snippets.filter((snippet) => snippet.field !== "title").slice(0, 2);
|
||||
const previewImageUrl = result.previewImageUrl;
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={result.href}
|
||||
disableIssueQuicklook
|
||||
className={cn(ROW_BASE, "py-3", isActive && "bg-muted/40", className)}
|
||||
className={cn(ROW_BASE, "py-4", isActive && "bg-muted/40", className)}
|
||||
data-result-type="issue"
|
||||
>
|
||||
<div className="mt-1 shrink-0">
|
||||
@@ -160,6 +161,15 @@ function SearchResultRowImpl({
|
||||
{updated ? <span className="ml-auto tabular-nums">{updated}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
{previewImageUrl ? (
|
||||
<img
|
||||
src={previewImageUrl}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="ml-2 mt-0.5 h-[88px] w-[88px] shrink-0 rounded-md border border-border bg-muted object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -577,20 +577,20 @@ function SearchTabContent({
|
||||
if (!hasResults) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col" data-testid="search-results">
|
||||
<div className="flex items-center justify-between px-4 py-2 text-[11px] uppercase tracking-wide text-muted-foreground">
|
||||
<div className="mx-auto flex w-full max-w-[680px] flex-col px-2 sm:px-4" data-testid="search-results">
|
||||
<div className="flex items-center justify-between py-2 text-[11px] uppercase tracking-wide text-muted-foreground">
|
||||
<span>
|
||||
{totalResults === 1 ? "1 result" : `${totalResults} results`} · sorted by relevance
|
||||
</span>
|
||||
{isFetching ? <span aria-live="polite" className="normal-case tracking-normal">Updating…</span> : null}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col pb-10">
|
||||
{scope === "all" ? (
|
||||
subgroups.map((group, groupIndex) => (
|
||||
<section
|
||||
key={group.key}
|
||||
aria-label={SUBGROUP_LABELS[group.key]}
|
||||
className={cn("flex flex-col", groupIndex > 0 && "mt-3 border-t border-border")}
|
||||
className={cn("flex flex-col", groupIndex > 0 && "mt-6")}
|
||||
>
|
||||
<IssueGroupHeader
|
||||
label={SUBGROUP_LABELS[group.key]}
|
||||
@@ -599,9 +599,9 @@ function SearchTabContent({
|
||||
{group.results.length}
|
||||
</span>
|
||||
}
|
||||
className="px-4 pt-2 pb-1 text-[11px] tracking-wider text-muted-foreground"
|
||||
className="pt-2 pb-1 text-[11px] tracking-wider text-muted-foreground"
|
||||
/>
|
||||
<div className="flex flex-col divide-y divide-border/50">
|
||||
<div className="flex flex-col gap-y-1">
|
||||
{group.results.map((result) => (
|
||||
<SearchResultRow
|
||||
key={`${result.type}:${result.id}:${result.href}`}
|
||||
@@ -613,7 +613,7 @@ function SearchTabContent({
|
||||
</section>
|
||||
))
|
||||
) : (
|
||||
<div className="flex flex-col divide-y divide-border/50">
|
||||
<div className="flex flex-col gap-y-1">
|
||||
{subgroups
|
||||
.flatMap((group) => group.results)
|
||||
.map((result) => (
|
||||
|
||||
@@ -62,6 +62,7 @@ function buildIssueResult(overrides: IssueResultOverrides): CompanySearchResult
|
||||
snippets: overrides.snippets ?? [],
|
||||
issue: baseIssue,
|
||||
updatedAt: baseIssue.updatedAt,
|
||||
previewImageUrl: overrides.previewImageUrl ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -112,6 +113,8 @@ const fixtureResults: CompanySearchResult[] = [
|
||||
highlights: [{ start: 12, end: 16 }, { start: 26, end: 30 }],
|
||||
},
|
||||
],
|
||||
previewImageUrl:
|
||||
"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' fill='%23a78bfa'/><text x='50' y='55' font-size='14' fill='white' text-anchor='middle' font-family='sans-serif'>chart</text></svg>",
|
||||
}),
|
||||
buildIssueResult({
|
||||
id: "issue-3",
|
||||
@@ -154,6 +157,7 @@ const fixtureAgents: CompanySearchResult[] = storybookAgents.slice(0, 1).map((ag
|
||||
]
|
||||
: [],
|
||||
updatedAt: new Date().toISOString(),
|
||||
previewImageUrl: null,
|
||||
}));
|
||||
|
||||
const fixtureProjects: CompanySearchResult[] = storybookProjects.slice(0, 1).map((project) => ({
|
||||
@@ -176,6 +180,7 @@ const fixtureProjects: CompanySearchResult[] = storybookProjects.slice(0, 1).map
|
||||
]
|
||||
: [],
|
||||
updatedAt: new Date().toISOString(),
|
||||
previewImageUrl: null,
|
||||
}));
|
||||
|
||||
const fixtureResponse: CompanySearchResponse = {
|
||||
@@ -261,8 +266,8 @@ function SearchPagePreview({
|
||||
</div>
|
||||
|
||||
{state === "results" ? (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center justify-between px-4 py-2 text-[11px] uppercase tracking-wide text-muted-foreground">
|
||||
<div className="mx-auto flex w-full max-w-[680px] flex-col px-2 sm:px-4">
|
||||
<div className="flex items-center justify-between py-2 text-[11px] uppercase tracking-wide text-muted-foreground">
|
||||
<span>{response.results.length} results · sorted by relevance</span>
|
||||
</div>
|
||||
<section aria-label="Issues" className="flex flex-col">
|
||||
@@ -273,9 +278,9 @@ function SearchPagePreview({
|
||||
{fixtureResults.length}
|
||||
</span>
|
||||
}
|
||||
className="px-4 pt-2 pb-1 text-[11px] tracking-wider text-muted-foreground"
|
||||
className="pt-2 pb-1 text-[11px] tracking-wider text-muted-foreground"
|
||||
/>
|
||||
<div className="flex flex-col divide-y divide-border/50">
|
||||
<div className="flex flex-col gap-y-1">
|
||||
{fixtureResults.map((result) => (
|
||||
<SearchResultRow
|
||||
key={result.id}
|
||||
@@ -285,7 +290,7 @@ function SearchPagePreview({
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section aria-label="Agents" className="mt-3 flex flex-col border-t border-border">
|
||||
<section aria-label="Agents" className="mt-6 flex flex-col">
|
||||
<IssueGroupHeader
|
||||
label="Agents"
|
||||
trailing={
|
||||
@@ -293,15 +298,15 @@ function SearchPagePreview({
|
||||
{fixtureAgents.length}
|
||||
</span>
|
||||
}
|
||||
className="px-4 pt-2 pb-1 text-[11px] tracking-wider text-muted-foreground"
|
||||
className="pt-2 pb-1 text-[11px] tracking-wider text-muted-foreground"
|
||||
/>
|
||||
<div className="flex flex-col divide-y divide-border/50">
|
||||
<div className="flex flex-col gap-y-1">
|
||||
{fixtureAgents.map((result) => (
|
||||
<SearchResultRow key={result.id} result={result} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section aria-label="Projects" className="mt-3 flex flex-col border-t border-border">
|
||||
<section aria-label="Projects" className="mt-6 flex flex-col">
|
||||
<IssueGroupHeader
|
||||
label="Projects"
|
||||
trailing={
|
||||
@@ -309,9 +314,9 @@ function SearchPagePreview({
|
||||
{fixtureProjects.length}
|
||||
</span>
|
||||
}
|
||||
className="px-4 pt-2 pb-1 text-[11px] tracking-wider text-muted-foreground"
|
||||
className="pt-2 pb-1 text-[11px] tracking-wider text-muted-foreground"
|
||||
/>
|
||||
<div className="flex flex-col divide-y divide-border/50">
|
||||
<div className="flex flex-col gap-y-1">
|
||||
{fixtureProjects.map((result) => (
|
||||
<SearchResultRow key={result.id} result={result} />
|
||||
))}
|
||||
@@ -572,7 +577,7 @@ function SearchStories() {
|
||||
<div className="paperclip-story__label">Search result row</div>
|
||||
<h2 className="mt-1 text-lg font-semibold">Issue, agent, project rows</h2>
|
||||
</div>
|
||||
<div className="flex flex-col divide-y divide-border">
|
||||
<div className="mx-auto flex w-full max-w-[680px] flex-col gap-y-1">
|
||||
{fixtureResults.map((result) => (
|
||||
<SearchResultRow
|
||||
key={result.id}
|
||||
|
||||
Reference in new issue
Block a user