feat: avatars, tinted icons and structured previews in global search results #641

This commit is contained in:
Linus Rath
2026-09-01 21:41:35 +02:00
parent a9cd4ced60
commit 96cdf3749f
4 changed files with 152 additions and 87 deletions
@@ -110,8 +110,11 @@ export function GlobalSearchPalette({ proShell = true }: { proShell?: boolean })
if (hits.length === 0 && status.errors.length === 0 && status.status !== 'loading') return null;
return (
<div key={kind} className="py-1">
<div className="flex items-center gap-2 px-3 py-1 text-xs font-medium text-muted-foreground uppercase tracking-wide">
<div className="flex items-center gap-2 px-3 pt-2 pb-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{t(`scope_${kind}`)}
<span className="inline-flex min-w-[18px] h-[18px] items-center justify-center rounded-full bg-muted px-1.5 text-[10px] font-semibold tabular-nums normal-case">
{hits.length}
</span>
{status.status === 'loading' && <Loader2 className="w-3 h-3 animate-spin" aria-label={t('searching')} />}
</div>
{hits.slice(0, MAX_ROWS_PER_KIND).map((hit) => (
+50 -23
View File
@@ -1,19 +1,43 @@
"use client";
import { BookUser, CalendarDays, FileText, Folder, Mail, Repeat } from "lucide-react";
import { CalendarDays, FileText, Folder, Repeat } from "lucide-react";
import { useTranslations } from "next-intl";
import { Avatar } from "@/components/ui/avatar";
import { parseSearchSnippet, type SnippetSegment } from "@/lib/search-snippet";
import type { GlobalSearchHit } from "@/lib/global-search/types";
import { getContactDisplayName, getContactPhotoUri, getContactPrimaryEmail } from "@/stores/contact-store";
import { cn } from "@/lib/utils";
function HitIcon({ hit }: { hit: GlobalSearchHit }) {
const className = "w-4 h-4 shrink-0 text-muted-foreground";
switch (hit.kind) {
case 'mail': return <Mail className={className} />;
case 'contacts': return <BookUser className={className} />;
case 'calendar': return <CalendarDays className={className} />;
case 'files': return hit.isFolder ? <Folder className={className} /> : <FileText className={className} />;
/**
* Leading visual, mirroring the mail list's identity language: real avatars
* for people (sender / contact), kind-tinted icon chips for events and files.
*/
function LeadingVisual({ hit }: { hit: GlobalSearchHit }) {
if (hit.kind === 'mail') {
const from = hit.email.from?.[0];
return <Avatar name={from?.name || undefined} email={from?.email || undefined} size="sm" className="mt-0.5 shrink-0" />;
}
if (hit.kind === 'contacts') {
return (
<Avatar
name={getContactDisplayName(hit.contact) || undefined}
email={getContactPrimaryEmail(hit.contact) || undefined}
contactPhotoUri={getContactPhotoUri(hit.contact)}
size="sm"
className="mt-0.5 shrink-0"
/>
);
}
const [Icon, tint] = hit.kind === 'calendar'
? [CalendarDays, "bg-violet-500/15 text-violet-600 dark:text-violet-400"]
: hit.isFolder
? [Folder, "bg-amber-500/15 text-amber-600 dark:text-amber-400"]
: [FileText, "bg-amber-500/15 text-amber-600 dark:text-amber-400"];
return (
<span className={cn("mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full", tint)}>
<Icon className="h-3.5 w-3.5" />
</span>
);
}
function Snippet({ value }: { value: string }) {
@@ -41,8 +65,9 @@ export interface PaletteResultRowProps {
}
/**
* One palette hit: icon, title, account · context subtitle, date; the server
* snippet with its `<mark>`ed terms when mail search returned one.
* One search hit: avatar/icon, title with the date right-aligned, account ·
* context subtitle, and the server snippet with its `<mark>`ed terms when
* mail search returned one. Used by both the palette and the search tab.
*/
export function PaletteResultRow({ hit, onOpen, className }: PaletteResultRowProps) {
const t = useTranslations('global_search');
@@ -57,29 +82,31 @@ export function PaletteResultRow({ hit, onOpen, className }: PaletteResultRowPro
onClick={() => onOpen(hit)}
data-hit-kind={hit.kind}
className={cn(
"w-full flex items-start gap-2.5 px-3 py-2 text-left rounded-md",
"hover:bg-muted focus:bg-muted focus:outline-none",
"w-full flex items-start gap-3 px-3 py-2 text-left rounded-md transition-colors duration-150",
"hover:bg-muted/50 focus:bg-muted focus:outline-none",
className,
)}
>
<span className="mt-0.5"><HitIcon hit={hit} /></span>
<LeadingVisual hit={hit} />
<span className="flex-1 min-w-0">
<span className="flex items-center gap-1.5 min-w-0">
<span className="truncate text-sm text-foreground">
{hit.kind === 'mail' && hit.snippet?.subject ? <Snippet value={hit.snippet.subject} /> : title}
<span className="flex items-baseline gap-2 min-w-0">
<span className="flex-1 min-w-0 flex items-center gap-1.5">
<span className="truncate text-sm font-medium text-foreground">
{hit.kind === 'mail' && hit.snippet?.subject ? <Snippet value={hit.snippet.subject} /> : title}
</span>
{hit.kind === 'calendar' && hit.isRecurring && (
<Repeat aria-label={t('recurring')} className="w-3 h-3 shrink-0 text-muted-foreground" />
)}
</span>
{hit.kind === 'calendar' && hit.isRecurring && (
<Repeat aria-label={t('recurring')} className="w-3 h-3 shrink-0 text-muted-foreground" />
{hit.date && (
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">{formatHitDate(hit.date)}</span>
)}
</span>
<span className="block truncate text-xs text-muted-foreground">{subtitle}</span>
<span className="block truncate text-xs text-muted-foreground mt-0.5">{subtitle}</span>
{snippet && (
<span className="block truncate text-xs text-muted-foreground"><Snippet value={snippet} /></span>
<span className="block truncate text-xs text-muted-foreground mt-0.5"><Snippet value={snippet} /></span>
)}
</span>
{hit.date && (
<span className="shrink-0 text-xs text-muted-foreground tabular-nums mt-0.5">{formatHitDate(hit.date)}</span>
)}
</button>
);
}
+93 -60
View File
@@ -1,12 +1,13 @@
"use client";
import { BookUser, CalendarDays, ExternalLink, FileText, Folder, Repeat } from "lucide-react";
import { AlignLeft, Building2, CalendarDays, Clock, ExternalLink, FileText, Folder, HardDrive, Mail, MapPin, Phone, Repeat, Users } from "lucide-react";
import { useTranslations } from "next-intl";
import { Avatar } from "@/components/ui/avatar";
import { ProEmailView } from "@/components/pro/pro-email-tab-body";
import type { CalendarHit, ContactHit, FileHit, GlobalSearchHit } from "@/lib/global-search/types";
import { getContactDisplayName } from "@/stores/contact-store";
import { getContactDisplayName, getContactPhotoUri, getContactPrimaryEmail } from "@/stores/contact-store";
import { useAuthStore } from "@/stores/auth-store";
import { formatFileSize } from "@/lib/utils";
import { cn, formatFileSize } from "@/lib/utils";
function formatDateTime(iso: string | null): string {
if (!iso) return '';
@@ -20,7 +21,7 @@ function OpenButton({ hit, onOpen, label }: { hit: GlobalSearchHit; onOpen: (hit
<button
type="button"
onClick={() => onOpen(hit)}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md border border-border text-xs text-muted-foreground hover:bg-muted hover:text-foreground cursor-pointer"
className="inline-flex shrink-0 items-center gap-1.5 px-2.5 py-1.5 rounded-md border border-border text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
>
<ExternalLink className="w-3.5 h-3.5" />
{label}
@@ -28,11 +29,45 @@ function OpenButton({ hit, onOpen, label }: { hit: GlobalSearchHit; onOpen: (hit
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
/** Card header shared by the non-mail previews: visual, title, subtitle, Open. */
function PreviewHeader({ visual, title, badge, subtitle, action }: {
visual: React.ReactNode;
title: React.ReactNode;
badge?: React.ReactNode;
subtitle: string;
action: React.ReactNode;
}) {
return (
<div className="text-sm">
<dt className="text-xs text-muted-foreground">{label}</dt>
<dd className="text-foreground break-words">{children}</dd>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
{visual}
<div className="min-w-0">
<h2 className="flex items-center gap-2 text-lg font-semibold text-foreground">
<span className="truncate">{title}</span>
{badge}
</h2>
<p className="text-sm text-muted-foreground truncate">{subtitle}</p>
</div>
</div>
{action}
</div>
);
}
function IconChip({ icon: Icon, tint }: { icon: typeof CalendarDays; tint: string }) {
return (
<span className={cn("flex h-10 w-10 shrink-0 items-center justify-center rounded-lg", tint)}>
<Icon className="h-5 w-5" />
</span>
);
}
/** One detail line: muted leading icon, wrapping value. */
function DetailRow({ icon: Icon, children, pre }: { icon: typeof Clock; children: React.ReactNode; pre?: boolean }) {
return (
<div className="flex items-start gap-2.5 text-sm">
<Icon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<span className={cn("min-w-0 text-foreground break-words", pre && "whitespace-pre-wrap")}>{children}</span>
</div>
);
}
@@ -44,27 +79,24 @@ function ContactPreview({ hit, onOpen, openLabel }: { hit: ContactHit; onOpen: (
const phones = Object.values(contact.phones ?? {}).map((p) => (p as { number?: string }).number).filter(Boolean);
const orgs = Object.values(contact.organizations ?? {}).map((o) => (o as { name?: string }).name).filter(Boolean);
return (
<div className="p-4 flex flex-col gap-3">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-3 min-w-0">
<BookUser className="w-8 h-8 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<h2 className="text-base font-semibold text-foreground truncate">{name}</h2>
<p className="text-xs text-muted-foreground truncate">{[hit.accountLabel, hit.subtitle].filter(Boolean).join(' · ')}</p>
</div>
</div>
<OpenButton hit={hit} onOpen={onOpen} label={openLabel} />
</div>
<dl className="flex flex-col gap-2">
{emails.map((email) => <Field key={email} label="@">{email}</Field>)}
{phones.map((phone) => <Field key={phone} label="#">{phone}</Field>)}
{orgs.map((org) => <Field key={org} label="">{org}</Field>)}
<div className="p-4 flex flex-col gap-4">
<PreviewHeader
visual={<Avatar name={name} email={getContactPrimaryEmail(contact) || undefined} contactPhotoUri={getContactPhotoUri(contact)} size="lg" className="shrink-0" />}
title={name}
subtitle={[hit.accountLabel, hit.subtitle].filter(Boolean).join(' · ')}
action={<OpenButton hit={hit} onOpen={onOpen} label={openLabel} />}
/>
<div className="border-t border-border" />
<dl className="flex flex-col gap-2.5">
{emails.map((email) => <DetailRow key={email} icon={Mail}>{email}</DetailRow>)}
{phones.map((phone) => <DetailRow key={phone} icon={Phone}>{phone}</DetailRow>)}
{orgs.map((org) => <DetailRow key={org} icon={Building2}>{org}</DetailRow>)}
</dl>
</div>
);
}
function CalendarPreview({ hit, onOpen, openLabel, t }: { hit: CalendarHit; onOpen: (hit: GlobalSearchHit) => void; openLabel: string; t: ReturnType<typeof useTranslations> }) {
function CalendarPreview({ hit, onOpen, openLabel, recurringLabel }: { hit: CalendarHit; onOpen: (hit: GlobalSearchHit) => void; openLabel: string; recurringLabel: string }) {
const event = hit.event;
const locations = Object.values(event.locations ?? {}).map((l) => l?.name).filter(Boolean);
const participants = Object.values(event.participants ?? {})
@@ -72,47 +104,44 @@ function CalendarPreview({ hit, onOpen, openLabel, t }: { hit: CalendarHit; onOp
.map((p) => p.name || p.email)
.filter(Boolean);
return (
<div className="p-4 flex flex-col gap-3">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-3 min-w-0">
<CalendarDays className="w-8 h-8 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<h2 className="flex items-center gap-1.5 text-base font-semibold text-foreground">
<span className="truncate">{hit.title}</span>
{hit.isRecurring && <Repeat aria-label={t('recurring')} className="w-3.5 h-3.5 shrink-0 text-muted-foreground" />}
</h2>
<p className="text-xs text-muted-foreground truncate">{[hit.accountLabel, hit.subtitle].filter(Boolean).join(' · ')}</p>
</div>
</div>
<OpenButton hit={hit} onOpen={onOpen} label={openLabel} />
</div>
<dl className="flex flex-col gap-2">
<Field label="">{formatDateTime(event.start)}</Field>
{locations.map((location) => <Field key={location} label="">{location}</Field>)}
{event.description && <Field label="">{event.description}</Field>}
{participants.length > 0 && <Field label="">{participants.join(', ')}</Field>}
<div className="p-4 flex flex-col gap-4">
<PreviewHeader
visual={<IconChip icon={CalendarDays} tint="bg-violet-500/15 text-violet-600 dark:text-violet-400" />}
title={hit.title}
badge={hit.isRecurring ? (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
<Repeat className="h-3 w-3" />
{recurringLabel}
</span>
) : undefined}
subtitle={[hit.accountLabel, hit.subtitle].filter(Boolean).join(' · ')}
action={<OpenButton hit={hit} onOpen={onOpen} label={openLabel} />}
/>
<div className="border-t border-border" />
<dl className="flex flex-col gap-2.5">
<DetailRow icon={Clock}>{formatDateTime(event.start)}</DetailRow>
{locations.map((location) => <DetailRow key={location} icon={MapPin}>{location}</DetailRow>)}
{event.description && <DetailRow icon={AlignLeft} pre>{event.description}</DetailRow>}
{participants.length > 0 && <DetailRow icon={Users}>{participants.join(', ')}</DetailRow>}
</dl>
</div>
);
}
function FilePreview({ hit, onOpen, openLabel }: { hit: FileHit; onOpen: (hit: GlobalSearchHit) => void; openLabel: string }) {
const Icon = hit.isFolder ? Folder : FileText;
return (
<div className="p-4 flex flex-col gap-3">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-3 min-w-0">
<Icon className="w-8 h-8 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<h2 className="text-base font-semibold text-foreground truncate">{hit.title}</h2>
<p className="text-xs text-muted-foreground truncate">{[hit.accountLabel, hit.folderPath].filter(Boolean).join(' · ')}</p>
</div>
</div>
<OpenButton hit={hit} onOpen={onOpen} label={openLabel} />
</div>
<dl className="flex flex-col gap-2">
{!hit.isFolder && <Field label="">{formatFileSize(hit.node.size)}{hit.node.type ? ` · ${hit.node.type}` : ''}</Field>}
{hit.date && <Field label="">{formatDateTime(hit.date)}</Field>}
<div className="p-4 flex flex-col gap-4">
<PreviewHeader
visual={<IconChip icon={hit.isFolder ? Folder : FileText} tint="bg-amber-500/15 text-amber-600 dark:text-amber-400" />}
title={hit.title}
subtitle={[hit.accountLabel, hit.folderPath].filter(Boolean).join(' · ')}
action={<OpenButton hit={hit} onOpen={onOpen} label={openLabel} />}
/>
<div className="border-t border-border" />
<dl className="flex flex-col gap-2.5">
{!hit.isFolder && <DetailRow icon={HardDrive}>{formatFileSize(hit.node.size)}{hit.node.type ? ` · ${hit.node.type}` : ''}</DetailRow>}
{hit.date && <DetailRow icon={Clock}>{formatDateTime(hit.date)}</DetailRow>}
<DetailRow icon={Folder}>{hit.folderPath}</DetailRow>
</dl>
</div>
);
@@ -132,7 +161,11 @@ export function SearchPreview({ hit, onOpen, onClose }: SearchPreviewProps) {
const activeAccountId = useAuthStore((s) => s.activeAccountId);
if (!hit) {
return <p className="p-6 text-sm text-muted-foreground text-center">{t('preview_empty')}</p>;
return (
<div className="flex h-full items-center justify-center p-6">
<p className="text-sm text-muted-foreground text-center">{t('preview_empty')}</p>
</div>
);
}
if (hit.kind === 'mail') {
@@ -150,6 +183,6 @@ export function SearchPreview({ hit, onOpen, onClose }: SearchPreviewProps) {
}
const openLabel = t('open_full');
if (hit.kind === 'contacts') return <ContactPreview hit={hit} onOpen={onOpen} openLabel={openLabel} />;
if (hit.kind === 'calendar') return <CalendarPreview hit={hit} onOpen={onOpen} openLabel={openLabel} t={t} />;
if (hit.kind === 'calendar') return <CalendarPreview hit={hit} onOpen={onOpen} openLabel={openLabel} recurringLabel={t('recurring')} />;
return <FilePreview hit={hit} onOpen={onOpen} openLabel={openLabel} />;
}
@@ -27,7 +27,7 @@ function Row({ hit, selected, onSelect, onOpen }: {
}) {
return (
<div onDoubleClick={() => onOpen(hit)}>
<PaletteResultRow hit={hit} onOpen={onSelect} className={cn(selected && "bg-muted")} />
<PaletteResultRow hit={hit} onOpen={onSelect} className={cn("rounded-none", selected && "bg-accent hover:bg-accent")} />
</div>
);
}
@@ -68,9 +68,11 @@ export function SearchResultList({ outcome, kinds, flat, selectedKey, onSelect,
if (hits.length === 0 && status.errors.length === 0 && status.status !== 'loading') return null;
return (
<section key={kind} className="pb-2">
<h3 className="flex items-center gap-2 px-3 py-1 text-xs font-medium text-muted-foreground uppercase tracking-wide sticky top-0 bg-background">
<h3 className="flex items-center gap-2 px-3 pt-3 pb-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider sticky top-0 z-10 bg-background">
{t(`scope_${kind}`)}
<span className="tabular-nums normal-case">{hits.length}{status.hasMore ? '+' : ''}</span>
<span className="inline-flex min-w-[18px] h-[18px] items-center justify-center rounded-full bg-muted px-1.5 text-[10px] font-semibold tabular-nums normal-case">
{hits.length}{status.hasMore ? '+' : ''}
</span>
{status.status === 'loading' && <Loader2 className="w-3 h-3 animate-spin" aria-label={t('searching')} />}
</h3>
{hits.map((hit) => (