mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: avatars, tinted icons and structured previews in global search results #641
This commit is contained in:
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
Reference in New Issue
Block a user