feat(web): contacts + unibox browsers, dropdown + field primitives, ErrorBoundary

Reliability:
- ErrorBoundary wraps every route. Silent white pages are gone — any
  uncaught render error now surfaces inside the panel with name,
  message, stack toggle, and back/retry buttons.
- Boundary keys on pathname so navigating away clears the error.

Campaigns blank fix:
- Drop the legacy HeadSelectMenu + Search components from the page
  (suspected layout/click-outside collisions inside the slim SectionBar).
- Rewrite using the new dropdown + SearchInput primitives.
- Add StatStrip with clickable filters (All / Active / Paused / Draft).
- Loading shows skeleton rows. Empty splits between "no campaigns at
  all" vs "no campaigns matching the current filter".

Dropdown primitive (web/src/components/ui/popover-menu.tsx):
- Brae-density popover menu — slim trigger, hairline border content
  surface, h-7 items, mono kbd accents. Built from scratch rather than
  via Radix so styles are authoritative and bundle stays small.
- Click-outside + Esc handling, controlled/uncontrolled open state,
  side ("bottom"|"top") + align ("start"|"center"|"end") positioning
  with viewport-aware clamping.
- SelectButton helper styled as a brae trigger pill.

Field primitive (web/src/components/ui/field.tsx):
- SearchInput + TextInput + Label. 28px tall, hairline border,
  sky focus ring. Replaces the half-dozen ad-hoc inputs across pages.
- SearchInput supports Enter onSubmit and inline clear button.

Contacts browser (ContactsTable.tsx, rewritten in place):
- Standalone view: PageTopbar (Import / Export / New) + StatStrip
  (All / Subscribed / Unsubscribed / In campaigns, clickable filters)
  + SectionBar (search + sort dropdown + filters).
- Embedded view: skips topbar/strip, drops into SectionBar — used
  inside /app/campaigns/[id]/leads.
- Dense table with avatar + name + email-mono, optional company /
  phone columns (hidden on smaller widths), subscribed/unsubscribed
  pill, campaigns count, created date.
- Bulk selection floats a footer bar with Edit / Delete / Clear.
- Load-more button for infinite scroll (preserves the existing
  useInfiniteQuery hook).
- Sort dropdown wired to the existing SearchContacts API params.

Unibox email browser:
- ConversationList: SectionBar header with count, SearchInput, all /
  unread tabs with unread count badge, dense rows with avatar +
  bold-when-unread sender + subject + preview + relative time.
  Unread items get a thin sky rail on the left margin.
- ConversationItem: relative time formatter, name extraction from
  "Name <email>" headers.
- ThreadView: 48px topbar (subject + mark-unread/archive/delete
  actions) + section bar (n messages / k participants) + a divided
  message stream + composer pinned to bottom.
- MessageBubble: no card chrome; just hairlines between messages.
  Sender avatar + bold name + mono email + recipient line + mono
  timestamp; prose-rendered body.
- ReplyComposer: edge-to-edge textarea with footer bar (Send,
  Schedule popover with "in 1h / tomorrow 9 / next Mon 9", Discard,
  char counter). ⌘+Enter to send.
This commit is contained in:
Matthew Meszaros
2026-05-23 04:17:42 +00:00
parent 8362633728
commit 30eff698c0
12 changed files with 1866 additions and 488 deletions
+203 -76
View File
@@ -1,17 +1,15 @@
import HeadSelectMenu from "@/components/app/head/HeadSelectMenu";
import SelectOption from "@/components/app/popup/select/SelectOption";
import Search from "@/components/app/Search";
import { useUserProfile } from "@/hooks/context/user";
import useCampaigns from "@/lib/api/hooks/app/campaigns/useCampaigns";
import type Folder from "@/lib/api/models/app/Folder";
import { RiFolderLine, RiSoundModuleLine } from "@remixicon/react";
import React, { useMemo } from "react";
import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import {
CalendarIcon,
FilterIcon,
MegaphoneIcon,
FolderIcon,
PauseIcon,
PlayIcon,
PlusIcon,
Settings2Icon,
} from "lucide-react";
import {
EmptyBlock,
@@ -19,29 +17,51 @@ import {
PageBody,
PageTopbar,
SectionBar,
Stat,
StatStrip,
TopbarAction,
} from "@/components/layout/Page";
import { SearchInput } from "@/components/ui/field";
import {
PopoverMenu,
PopoverMenuContent,
PopoverMenuItem,
PopoverMenuLabel,
PopoverMenuSeparator,
PopoverMenuTrigger,
SelectButton,
} from "@/components/ui/popover-menu";
const DefaultFolder = {
title: "All folders",
color: "#c4c8cf",
} as Folder;
type StatusFilter = "all" | "active" | "paused" | "draft";
export default function CampaignsPage() {
const [folder, setFolder] = React.useState<string>("");
const [query, setQuery] = React.useState<string>("");
const campaignsData = useCampaigns({ query, folder });
const p = useUserProfile();
const [folder, setFolder] = useState<string>("");
const [query, setQuery] = useState<string>("");
const [status, setStatus] = useState<StatusFilter>("all");
const sfolder = useMemo(() => {
if (!p) return DefaultFolder;
const f = p.user.folders.find((f) => f.id === folder);
if (!f) return DefaultFolder;
return f;
}, [folder, p]);
const campaignsData = useCampaigns({ query, folder });
const campaigns = campaignsData.campaigns ?? [];
const folders = p.user.folders ?? [];
const activeFolder = folders.find((f) => f.id === folder);
const filtered = useMemo(
() => (status === "all" ? campaigns : campaigns.filter((c) => (c.status ?? "draft") === status)),
[campaigns, status],
);
const counts = useMemo(() => {
const stats = { total: campaigns.length, active: 0, paused: 0, draft: 0 };
for (const c of campaigns) {
const s = c.status ?? "draft";
if (s === "active") stats.active++;
else if (s === "paused") stats.paused++;
else stats.draft++;
}
return stats;
}, [campaigns]);
return (
<Page>
<PageTopbar
@@ -55,53 +75,116 @@ export default function CampaignsPage() {
}
>
<TopbarAction
onClick={() => {
/* TODO: open new-campaign modal */
}}
icon={<PlusIcon className="w-3 h-3" />}
variant="ghost"
icon={<Settings2Icon className="w-3 h-3" />}
>
Folders
</TopbarAction>
<TopbarAction icon={<PlusIcon className="w-3 h-3" />}>
New campaign
</TopbarAction>
</PageTopbar>
<SectionBar label="All" count={campaigns.length}>
<Search value={query} onChange={(v) => setQuery(v)} />
<HeadSelectMenu icon={<FilterIcon className="w-3.5 h-3.5" />} title={sfolder.title}>
{p?.user.folders.map((fo) => (
<SelectOption
key={fo.id}
onClick={async () => (folder !== fo.id ? setFolder(fo.id) : setFolder(""))}
color={fo.color}
selected={folder === fo.id}
<StatStrip cols={4}>
<Stat
label="All"
value={counts.total}
sub="campaigns"
onClick={() => setStatus("all")}
/>
<Stat
label="Active"
value={counts.active}
sub="sending now"
accent={counts.active > 0}
onClick={() => setStatus("active")}
/>
<Stat
label="Paused"
value={counts.paused}
sub="resumable"
onClick={() => setStatus("paused")}
/>
<Stat
label="Draft"
value={counts.draft}
sub="not started"
last
onClick={() => setStatus("draft")}
/>
</StatStrip>
<SectionBar
label={status === "all" ? "All campaigns" : `${status[0].toUpperCase()}${status.slice(1)}`}
count={filtered.length}
>
<SearchInput
value={query}
onChange={setQuery}
placeholder="Search campaigns…"
className="w-56"
/>
<PopoverMenu align="end">
<PopoverMenuTrigger asChild>
<SelectButton
icon={<FolderIcon className="w-3.5 h-3.5" />}
label={activeFolder?.title ?? "All folders"}
/>
</PopoverMenuTrigger>
<PopoverMenuContent minWidth={200}>
<PopoverMenuLabel>Folders</PopoverMenuLabel>
<PopoverMenuItem
onSelect={() => setFolder("")}
selected={!folder}
>
<RiFolderLine className="w-3.5 h-3.5" />
<span className="truncate">{fo.title}</span>
</SelectOption>
))}
<SelectOption onClick={() => p?.setFoldersEdit(true)}>
<RiSoundModuleLine className="w-3.5 h-3.5" />
<span className="truncate">Manage folders</span>
</SelectOption>
</HeadSelectMenu>
All folders
</PopoverMenuItem>
{folders.map((f) => (
<PopoverMenuItem
key={f.id}
onSelect={() => setFolder(folder === f.id ? "" : f.id)}
icon={<span className="size-2 rounded-full" style={{ backgroundColor: f.color }} />}
selected={folder === f.id}
>
{f.title}
</PopoverMenuItem>
))}
<PopoverMenuSeparator />
<PopoverMenuItem
onSelect={() => p.setFoldersEdit(true)}
icon={<Settings2Icon className="w-3 h-3" />}
>
Manage folders
</PopoverMenuItem>
</PopoverMenuContent>
</PopoverMenu>
<PopoverMenu align="end">
<PopoverMenuTrigger asChild>
<SelectButton
icon={<FilterIcon className="w-3.5 h-3.5" />}
label="More"
/>
</PopoverMenuTrigger>
<PopoverMenuContent>
<PopoverMenuLabel>Sort</PopoverMenuLabel>
<PopoverMenuItem selected>Newest first</PopoverMenuItem>
<PopoverMenuItem>Oldest first</PopoverMenuItem>
<PopoverMenuItem>Name (AZ)</PopoverMenuItem>
</PopoverMenuContent>
</PopoverMenu>
</SectionBar>
<PageBody>
{campaignsData.isPending ? (
<div className="divide-y divide-slate-200/60">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-11 px-5 flex items-center gap-3">
<div className="w-1.5 h-1.5 rounded-full bg-slate-200" />
<div className="h-3 w-44 bg-slate-100 rounded animate-pulse" />
<div className="ml-auto h-3 w-12 bg-slate-100 rounded animate-pulse" />
</div>
))}
</div>
<SkeletonRows />
) : campaignsData.isError ? (
<EmptyBlock
title="Couldn't load campaigns"
body={
campaignsData.error?.message ||
"The request failed. Check that the backend is up and you're signed in."
"The request failed. Check the backend is up."
}
cta={
<TopbarAction onClick={() => campaignsData.refetch()} variant="ghost">
@@ -109,26 +192,42 @@ export default function CampaignsPage() {
</TopbarAction>
}
/>
) : campaigns.length === 0 ? (
<EmptyBlock
title="No campaigns yet"
body="Create your first sequence to start reaching prospects."
cta={
<TopbarAction icon={<PlusIcon className="w-3 h-3" />}>
New campaign
</TopbarAction>
}
/>
) : filtered.length === 0 ? (
campaigns.length === 0 ? (
<EmptyBlock
title="No campaigns yet"
body="Create your first sequence to start reaching prospects."
cta={
<TopbarAction icon={<PlusIcon className="w-3 h-3" />}>
New campaign
</TopbarAction>
}
/>
) : (
<EmptyBlock
title={`No ${status} campaigns`}
body={`Switch to “All” to see every sequence.`}
cta={
<TopbarAction onClick={() => setStatus("all")} variant="ghost">
Show all
</TopbarAction>
}
/>
)
) : (
<div className="divide-y divide-slate-200/60">
{campaigns.map((c) => {
const status = c.status || "draft";
{filtered.map((c) => {
const cstatus = c.status ?? "draft";
const dot =
status === "active"
cstatus === "active"
? "bg-emerald-500"
: status === "draft"
? "bg-slate-300"
: "bg-amber-500";
: cstatus === "paused"
? "bg-amber-500"
: "bg-slate-300";
const stateLabel =
cstatus === "active" ? "running" : cstatus;
const StateIcon =
cstatus === "active" ? PauseIcon : PlayIcon;
return (
<Link
key={c.id}
@@ -136,11 +235,10 @@ export default function CampaignsPage() {
className="group h-11 px-5 flex items-center gap-3 hover:bg-slate-50 transition-colors"
>
<span className={`size-1.5 rounded-full shrink-0 ${dot}`} />
<MegaphoneIcon className="w-3.5 h-3.5 text-slate-400 shrink-0" />
<span className="text-[12.5px] text-slate-900 font-medium truncate">
<span className="text-[12.5px] text-slate-900 font-medium truncate max-w-[40%]">
{c.name}
</span>
<span className="font-mono text-[10.5px] text-slate-400 tabular-nums">
<span className="font-mono text-[10.5px] text-slate-400 tabular-nums shrink-0">
{c.id.slice(0, 8)}
</span>
{c.description && (
@@ -148,18 +246,32 @@ export default function CampaignsPage() {
{c.description}
</span>
)}
<span className="ml-auto text-[10px] uppercase tracking-[0.1em] text-slate-400 font-medium">
{status}
<span className="ml-auto text-[10px] uppercase tracking-[0.1em] text-slate-500 font-medium shrink-0">
{stateLabel}
</span>
<span className="font-mono text-[10.5px] text-slate-400 tabular-nums flex items-center gap-1">
<span className="font-mono text-[10.5px] text-slate-400 tabular-nums flex items-center gap-1 shrink-0">
<CalendarIcon className="w-3 h-3" />
{c.created_at
? new Date(c.created_at).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})
: "--"}
: ""}
</span>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
/* TODO toggle */
}}
className="size-6 rounded text-slate-400 hover:text-slate-900 hover:bg-slate-100 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
aria-label={
cstatus === "active" ? "Pause campaign" : "Start campaign"
}
>
<StateIcon className="w-3.5 h-3.5" />
</button>
</Link>
);
})}
@@ -169,3 +281,18 @@ export default function CampaignsPage() {
</Page>
);
}
function SkeletonRows() {
return (
<div className="divide-y divide-slate-200/60">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="h-11 px-5 flex items-center gap-3">
<div className="size-1.5 rounded-full bg-slate-200" />
<div className="h-3 w-44 bg-slate-100 rounded animate-pulse" />
<div className="font-mono h-3 w-12 bg-slate-100 rounded animate-pulse" />
<div className="ml-auto h-3 w-16 bg-slate-100 rounded animate-pulse" />
</div>
))}
</div>
);
}
+37 -26
View File
@@ -1,31 +1,42 @@
import { ConversationList } from '@/components/app/unibox/ConversationList'
import { ThreadView } from '@/components/app/unibox/ThreadView'
import { useAppStore } from '@/stores'
import { InboxIcon } from 'lucide-react'
// Unibox page — three-pane mail browser.
//
// ┌────────────────┬──────────────────────────────────────┐
// │ ConversationList │ ThreadView │
// │ (340px) │ (fills remainder) │
// └────────────────┴──────────────────────────────────────┘
import { ConversationList } from "@/components/app/unibox/ConversationList";
import { ThreadView } from "@/components/app/unibox/ThreadView";
import { useAppStore } from "@/stores";
import { InboxIcon } from "lucide-react";
export default function UniboxPage() {
const selectedThreadId = useAppStore((s) => s.selectedThreadId)
const selectedThreadId = useAppStore((s) => s.selectedThreadId);
return (
<div className="flex h-[calc(100vh-theme(spacing.11))] gap-0">
<div className="w-80 shrink-0 border-r border-zinc-200 overflow-hidden flex flex-col">
<ConversationList />
</div>
<div className="flex-1 overflow-hidden flex flex-col">
{selectedThreadId ? (
<ThreadView threadId={selectedThreadId} />
) : (
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<div className="w-10 h-10 rounded-xl bg-zinc-100 flex items-center justify-center mx-auto mb-3">
<InboxIcon className="w-4 h-4 text-zinc-400" />
</div>
<p className="text-sm font-medium text-zinc-900">Select a conversation</p>
<p className="text-xs text-zinc-400 mt-0.5">Choose a thread from the left to view messages</p>
return (
<div className="flex h-full bg-white">
<div className="w-[340px] shrink-0 border-r border-slate-200 overflow-hidden flex flex-col">
<ConversationList />
</div>
</div>
)}
</div>
</div>
)
<div className="flex-1 min-w-0 overflow-hidden flex flex-col">
{selectedThreadId ? (
<ThreadView threadId={selectedThreadId} />
) : (
<div className="flex-1 flex items-center justify-center">
<div className="text-center px-5">
<div className="w-8 h-8 rounded-md bg-slate-100 flex items-center justify-center mx-auto mb-3 text-slate-400">
<InboxIcon className="w-4 h-4" />
</div>
<p className="text-[12.5px] font-medium text-slate-700">
Select a conversation
</p>
<p className="text-[11.5px] text-slate-400 mt-1 max-w-[34ch] leading-relaxed">
Pick a thread from the list to read and reply.
</p>
</div>
</div>
)}
</div>
</div>
);
}
+625 -190
View File
@@ -1,240 +1,675 @@
import { RiAddLine, RiEqualizer3Line, RiTeamLine } from "@remixicon/react";
// Contacts browser — brae-density rewrite.
//
// Visible chrome: PageTopbar > StatStrip > SectionBar > scroll body.
// Body is a dense table where each row is h-11, hairline divider, hover
// row reveals quick actions. Selecting rows pops a footer action bar.
//
// Works in two contexts:
// - /app/contacts → full standalone browser.
// - /app/campaigns/[id]/leads → scoped to a single campaign; the
// parent passes `current_campaign` and the topbar collapses to a
// section header so it nests cleanly under the campaign view.
import React from "react";
import { Loading } from "../../loader";
import {
Building2Icon,
CheckIcon,
DownloadIcon,
Loader2Icon,
MailIcon,
MoreHorizontalIcon,
PhoneIcon,
PlusIcon,
Settings2Icon,
TrashIcon,
UploadIcon,
UserPlusIcon,
} from "lucide-react";
import { useConfirm } from "@/hooks/context/confirm";
import Checkbox from "../Checkbox";
import { twColors } from "tailwindv4-colors";
import useSearchContacts from "@/lib/api/hooks/app/contacts/useSearchContacts";
import type SearchContacts from "@/lib/api/models/app/contacts/SearchContacts";
import HeadSearch from "../head/HeadSearch";
import HeadMenu from "../head/HeadMenu";
import HeadButton from "../head/HeadButton";
import useDeleteContacts from "@/lib/api/hooks/app/contacts/useDeleteContacts";
import toast from "react-hot-toast";
import type { AppError } from "@/lib/api/client/normalizeError";
import buildError from "@/lib/helper/buildError";
import ContactFilters from "./ContactFilters";
import ContactEntry from "./ContactEntry";
import ContactEdit from "./ContactEdit";
import type MiniCampaign from "@/lib/api/models/app/campaigns/MiniCampaign";
import ContactsEditBulk from "./ContactsEditBulk";
import {
EmptyBlock,
Page,
PageBody,
PageTopbar,
SectionBar,
Stat,
StatStrip,
TopbarAction,
} from "@/components/layout/Page";
import { SearchInput } from "@/components/ui/field";
import {
PopoverMenu,
PopoverMenuContent,
PopoverMenuItem,
PopoverMenuLabel,
PopoverMenuSeparator,
PopoverMenuTrigger,
SelectButton,
} from "@/components/ui/popover-menu";
type SubFilter = "all" | "subscribed" | "unsubscribed";
export default function ContactsTable({
current_campaign,
}: {
current_campaign?: MiniCampaign,
current_campaign?: MiniCampaign;
}) {
const confirm = useConfirm();
const [selected, setSelected] = React.useState<string[]>([]);
const [del, setDelete] = React.useState<boolean>(false);
const [filters, setFilters] = React.useState<boolean>(false);
const [filtersOpen, setFiltersOpen] = React.useState<boolean>(false);
const [edit, setEdit] = React.useState<string>("");
const [bulkEdit, setBulkEdit] = React.useState<boolean>(false);
const [subFilter, setSubFilter] = React.useState<SubFilter>("all");
const [searchProps, setSearchProps] = React.useState<SearchContacts>({
query: "",
filters: [],
campaign_ids: current_campaign ? [current_campaign.id] : [],
sort_by: 'created_at',
sort_by: "created_at",
reverse: false,
})
const contactsData = useSearchContacts({
options: searchProps,
});
const contactsData = useSearchContacts({ options: searchProps });
const contactsBulkDelete = useDeleteContacts();
const contacts = contactsData.contacts;
const total = contactsData.data?.pages[0]?.pagination.total ?? 0;
const filtered = React.useMemo(() => {
if (!contacts) return [];
if (subFilter === "all") return contacts;
return contacts.filter((c) =>
subFilter === "subscribed" ? c.subscribed : !c.subscribed,
);
}, [contacts, subFilter]);
const counts = React.useMemo(() => {
const stats = { total: contacts?.length ?? 0, subscribed: 0, unsubscribed: 0, inCampaign: 0 };
for (const c of contacts ?? []) {
if (c.subscribed) stats.subscribed++;
else stats.unsubscribed++;
if (c.campaigns && c.campaigns.length > 0) stats.inCampaign++;
}
return stats;
}, [contacts]);
const isSelectedAll = React.useMemo(() => {
if (!contactsData) return false;
return !contactsData.contacts?.some((v) => !selected.some((s) => s === v.id))
}, [selected, contactsData])
if (!filtered.length) return false;
return filtered.every((v) => selected.includes(v.id));
}, [filtered, selected]);
async function BulkDelete() {
function toggleAll() {
if (isSelectedAll) {
setSelected((bef) => bef.filter((id) => !filtered.some((c) => c.id === id)));
} else {
setSelected((bef) => Array.from(new Set([...bef, ...filtered.map((c) => c.id)])));
}
}
async function bulkDelete() {
if (selected.length === 0) return;
try {
confirm?.setLoading(true);
const cselected = selected;
const ids = selected;
try {
setDelete(true);
await toast.promise(
contactsBulkDelete.mutateAsync(cselected),
{
loading: `Deleting ${cselected.length} contacts...`,
success: "Contacts successfully deleted.",
error: (err: AppError) => buildError(err),
}
)
await toast.promise(contactsBulkDelete.mutateAsync(ids), {
loading: `Deleting ${ids.length} contacts…`,
success: "Contacts deleted",
error: (err: AppError) => buildError(err),
});
setSelected([]);
} finally {
setDelete(false);
}
} finally {
confirm?.setLoading(false)
confirm?.setShow(false)
confirm?.setLoading(false);
confirm?.setShow(false);
}
}
return (<>
<div className={`${current_campaign ? "px-0" : "lg:px-5"} w-full`}>
<div className="flex flex-col xl:flex-row justify-between w-full gap-4 min-w-0 overflow-x-hidden">
<HeadSearch
loading={contactsData.isLoading}
onSubmit={async (e, search) => {
e.preventDefault();
setSearchProps(props => ({
...props,
search,
}))
}}
/>
<HeadMenu>
<HeadButton white onClick={() => { }}>
<RiEqualizer3Line className="w-4" />
Filters
</HeadButton>
<HeadButton onClick={() => { }}>
<RiAddLine className="w-4" />
<div>New Contact</div>
</HeadButton>
</HeadMenu>
</div>
{!contactsData.contacts ? (
<div className="animate-pulse space-y-3 mt-10 w-full">
<div className="bg-gray-300 h-[86px] rounded-lg" />
<div className="bg-gray-300 h-[86px] rounded-lg" />
<div className="bg-gray-300 h-[86px] rounded-lg" />
<div className="bg-gray-300 h-[86px] rounded-lg" />
</div>
) : (
<>
{contactsData.contacts?.length === 0 ? (
<div className="py-30 flex items-center justify-center gap-9 w-full flex-col">
<RiTeamLine className="w-20 h-20 text-slate-300" />
<h1 className="font-inter font-bold text-3xl text-gray-600">It looks empty here! </h1>
<p className="text-slate-500 max-w-lg text-lg text-center">Start building your contact list by adding or uploading new contacts. Add your contacts to get started.</p>
</div>
) : (<>
<p className="text-slate-400 mt-6 px-4 leading-8 flex gap-3 items-center">Showing {contactsData.contacts.length} of {contactsData.data?.pages[0]?.pagination.total ?? 0}{selected.length > 0 && <>
{` (${selected.length} selected)`}
<button
className={`bg-slate-200 ripple hover:bg-slate-300 shrink-0 rounded-lg text-slate-500 flex items-center justify-center w-21 transition cursor-pointer`}
onClick={() => setBulkEdit(true)}>
Bulk Edit
</button>
<button
className={`${del ? "bg-red-200" : "bg-red-100 hover:bg-red-200"} shrink-0 ripple rounded-lg text-red-500 flex items-center justify-center w-16 cursor-pointer transition`}
onClick={() => confirm?.show(`Are you sure you want to delete ${selected.length} selected contacts?`, BulkDelete)}>
{del ? <Loading className="h-5" /> : "Delete"}
</button>
</>}</p>
<div className="grid grid-cols-1 overflow-x-auto whitespace-nowrap">
<table className="text-sm text-left rtl:text-right text-gray-500">
<thead className="text-xs text-gray-700 uppercase">
<tr>
<th scope="col" className="p-4">
<div className="flex items-center">
<input
id="checkbox-all-contacts"
type="checkbox"
className="hidden"
onChange={() => {
if (isSelectedAll) {
setSelected([])
} else {
if (!contactsData.contacts) {
setSelected([])
} else {
setSelected(contactsData.contacts.map((c) => c.id))
}
}
}}
/>
<label className="cursor-pointer" htmlFor="checkbox-all-contacts">
<Checkbox
checked={isSelectedAll}
/>
</label>
</div>
</th>
<th scope="col" className="px-6 py-3 whitespace-nowrap">
First Name
</th>
<th scope="col" className="px-6 py-3 whitespace-nowrap">
Last Name
</th>
<th scope="col" className="px-6 py-3 whitespace-nowrap">
Email
</th>
<th scope="col" className="px-6 py-3 whitespace-nowrap">
Company
</th>
<th scope="col" className="px-6 py-3 whitespace-nowrap">
Phone
</th>
<th scope="col" className="px-6 py-3 whitespace-nowrap">
Subscribed
</th>
<th scope="col" className="px-6 py-3 whitespace-nowrap">
Campaigns
</th>
<th scope="col" className="px-6 py-3 whitespace-nowrap">
Custom Fields
</th>
<th scope="col" className="px-6 py-3 whitespace-nowrap">
Action
</th>
</tr>
</thead>
<tbody>
{contactsData.contacts.map((contact, index) => {
const isSelected = selected.some((s) => s === contact.id)
return (
<ContactEntry
key={index}
contact={contact}
index={index}
isSelected={isSelected}
onSelection={() => {
if (isSelected) {
setSelected(bef => bef.filter((s) => s !== contact.id))
} else {
setSelected(bef => [...bef, contact.id])
}
}}
onEdit={() => { }}
/>
)
})}
</tbody>
</table>
</div>
{contactsData.hasNextPage &&
<div className="flex w-full justify-center py-4">
<button className={`h-10 w-30 rounded-lg cursor-pointer transition ${contactsData.isLoading ? "bg-blue-200" : "bg-blue-100 hover:bg-blue-200"} flex items-center justify-center text-blue-500`}>
{contactsData.isLoading ? <Loading className="h-5" color={twColors.blue[500]} /> : "Load More"}
</button>
</div>}
</>)}
</>
)}
</div>
<ContactFilters
active={filters}
setActive={setFilters}
filters={searchProps}
setFilters={setSearchProps}
activeCampaign={current_campaign}
loading={contactsData.isLoading}
/>
<ContactEdit
contacts={contactsData.contacts ?? []}
active={edit}
setActive={setEdit}
/>
<ContactsEditBulk
active={bulkEdit}
setActive={setBulkEdit}
const embedded = !!current_campaign;
const tableNode = (
<ContactsTableBody
isLoading={!contacts}
contacts={filtered}
selected={selected}
onToggle={(id, on) =>
setSelected((bef) => (on ? [...bef, id] : bef.filter((x) => x !== id)))
}
isSelectedAll={isSelectedAll}
onToggleAll={toggleAll}
onRowClick={setEdit}
onDelete={(id) =>
confirm?.show(`Delete this contact?`, async () => {
setSelected([id]);
await bulkDelete();
})
}
emptyTitle={
subFilter !== "all"
? `No ${subFilter} contacts`
: current_campaign
? "No contacts in this campaign"
: "No contacts yet"
}
emptyBody={
subFilter !== "all"
? "Switch to All to see the full list."
: "Add or upload contacts to get started."
}
emptyCta={
subFilter !== "all" ? (
<TopbarAction variant="ghost" onClick={() => setSubFilter("all")}>
Show all
</TopbarAction>
) : (
<TopbarAction icon={<UserPlusIcon className="w-3 h-3" />}>New contact</TopbarAction>
)
}
hasNextPage={!!contactsData.hasNextPage}
isFetchingNextPage={contactsData.isFetchingNextPage}
onLoadMore={() => contactsData.fetchNextPage()}
/>
</>)
);
if (embedded) {
return (
<>
<SectionBar label="Leads" count={total}>
<SearchInput
value={searchProps.query}
onChange={(v) => setSearchProps((s) => ({ ...s, query: v }))}
placeholder="Search leads…"
className="w-56"
/>
<TopbarAction
variant="ghost"
icon={<Settings2Icon className="w-3 h-3" />}
onClick={() => setFiltersOpen(true)}
>
Filters
</TopbarAction>
<TopbarAction icon={<UserPlusIcon className="w-3 h-3" />}>Add lead</TopbarAction>
</SectionBar>
<div className="relative">
{tableNode}
<SelectionBar
count={selected.length}
deleting={del}
onBulkEdit={() => setBulkEdit(true)}
onDelete={() =>
confirm?.show(
`Are you sure you want to delete ${selected.length} contacts?`,
bulkDelete,
)
}
onClear={() => setSelected([])}
/>
</div>
<ContactFilters
active={filtersOpen}
setActive={setFiltersOpen}
filters={searchProps}
setFilters={setSearchProps}
activeCampaign={current_campaign}
loading={contactsData.isLoading}
/>
<ContactEdit
contacts={contacts ?? []}
active={edit}
setActive={setEdit}
/>
<ContactsEditBulk active={bulkEdit} setActive={setBulkEdit} selected={selected} />
</>
);
}
return (
<Page>
<PageTopbar
eyebrow="Contacts"
subtitle={
contactsData.isPending
? "Loading…"
: contactsData.isError
? "Failed to load"
: `${total.toLocaleString()} total`
}
>
<TopbarAction
variant="ghost"
icon={<UploadIcon className="w-3 h-3" />}
>
Import CSV
</TopbarAction>
<TopbarAction
variant="ghost"
icon={<DownloadIcon className="w-3 h-3" />}
>
Export
</TopbarAction>
<TopbarAction icon={<UserPlusIcon className="w-3 h-3" />}>
New contact
</TopbarAction>
</PageTopbar>
<StatStrip cols={4}>
<Stat
label="All"
value={counts.total}
sub="on this page"
onClick={() => setSubFilter("all")}
/>
<Stat
label="Subscribed"
value={counts.subscribed}
sub="receiving mail"
accent={counts.subscribed > 0}
onClick={() => setSubFilter("subscribed")}
/>
<Stat
label="Unsubscribed"
value={counts.unsubscribed}
sub="suppressed"
onClick={() => setSubFilter("unsubscribed")}
/>
<Stat
label="In campaigns"
value={counts.inCampaign}
sub="active touchpoints"
last
/>
</StatStrip>
<SectionBar
label={subFilter === "all" ? "All contacts" : `${subFilter[0].toUpperCase()}${subFilter.slice(1)}`}
count={filtered.length}
>
<SearchInput
value={searchProps.query}
onChange={(v) => setSearchProps((s) => ({ ...s, query: v }))}
placeholder="Search by name, email, company…"
className="w-72"
/>
<PopoverMenu align="end">
<PopoverMenuTrigger asChild>
<SelectButton
icon={<Settings2Icon className="w-3.5 h-3.5" />}
label="Sort"
/>
</PopoverMenuTrigger>
<PopoverMenuContent>
<PopoverMenuLabel>Sort by</PopoverMenuLabel>
{[
["created_at", "Date added"],
["email", "Email"],
["first_name", "First name"],
["last_name", "Last name"],
["company", "Company"],
].map(([key, label]) => (
<PopoverMenuItem
key={key}
selected={searchProps.sort_by === key}
onSelect={() =>
setSearchProps((s) => ({
...s,
sort_by: key as SearchContacts["sort_by"],
}))
}
>
{label}
</PopoverMenuItem>
))}
<PopoverMenuSeparator />
<PopoverMenuItem
selected={searchProps.reverse}
onSelect={() => setSearchProps((s) => ({ ...s, reverse: !s.reverse }))}
closeOnSelect={false}
>
Reverse order
</PopoverMenuItem>
</PopoverMenuContent>
</PopoverMenu>
<TopbarAction
variant="ghost"
icon={<Settings2Icon className="w-3 h-3" />}
onClick={() => setFiltersOpen(true)}
>
Filters
{searchProps.filters.length > 0 && (
<span className="ml-1 font-mono text-[10px] text-sky-600 tabular-nums">
{searchProps.filters.length}
</span>
)}
</TopbarAction>
</SectionBar>
<PageBody>
{tableNode}
</PageBody>
<SelectionBar
count={selected.length}
deleting={del}
onBulkEdit={() => setBulkEdit(true)}
onDelete={() =>
confirm?.show(
`Are you sure you want to delete ${selected.length} contacts?`,
bulkDelete,
)
}
onClear={() => setSelected([])}
/>
{filtered.length === 0 && !contactsData.isPending ? null : null}
<ContactFilters
active={filtersOpen}
setActive={setFiltersOpen}
filters={searchProps}
setFilters={setSearchProps}
activeCampaign={current_campaign}
loading={contactsData.isLoading}
/>
<ContactEdit contacts={contacts ?? []} active={edit} setActive={setEdit} />
<ContactsEditBulk active={bulkEdit} setActive={setBulkEdit} selected={selected} />
</Page>
);
}
function ContactsTableBody({
isLoading,
contacts,
selected,
onToggle,
isSelectedAll,
onToggleAll,
onRowClick,
onDelete,
emptyTitle,
emptyBody,
emptyCta,
hasNextPage,
isFetchingNextPage,
onLoadMore,
}: {
isLoading: boolean;
contacts: Array<{
id: string;
first_name: string;
last_name: string;
email: string;
company: string;
phone: string;
subscribed: boolean;
campaigns: Array<{ id: string }>;
created_at: Date;
}>;
selected: string[];
onToggle: (id: string, on: boolean) => void;
isSelectedAll: boolean;
onToggleAll: () => void;
onRowClick: (id: string) => void;
onDelete: (id: string) => void;
emptyTitle: string;
emptyBody: string;
emptyCta: React.ReactNode;
hasNextPage: boolean;
isFetchingNextPage: boolean;
onLoadMore: () => void;
}) {
if (isLoading) {
return (
<div className="divide-y divide-slate-200/60">
{Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="h-11 px-5 flex items-center gap-3">
<div className="w-3.5 h-3.5 bg-slate-100 rounded" />
<div className="w-6 h-6 rounded-full bg-slate-100 shrink-0" />
<div className="h-3 w-40 bg-slate-100 rounded animate-pulse" />
<div className="h-3 w-32 bg-slate-100 rounded animate-pulse ml-6" />
<div className="ml-auto h-3 w-16 bg-slate-100 rounded animate-pulse" />
</div>
))}
</div>
);
}
if (contacts.length === 0) {
return <EmptyBlock title={emptyTitle} body={emptyBody} cta={emptyCta} />;
}
return (
<>
<table className="w-full text-left">
<thead className="sticky top-0 bg-white z-[1]">
<tr className="border-b border-slate-200">
<th className="pl-5 pr-2 py-2 w-9">
<input
type="checkbox"
className="w-3.5 h-3.5 rounded accent-sky-600"
checked={isSelectedAll}
onChange={onToggleAll}
/>
</th>
<Th>Name</Th>
<Th className="hidden md:table-cell">Company</Th>
<Th className="hidden lg:table-cell">Phone</Th>
<Th className="w-28">Status</Th>
<Th className="w-24 text-right">Campaigns</Th>
<Th className="w-24 text-right hidden md:table-cell">Added</Th>
<th className="px-3 py-2 w-12"></th>
</tr>
</thead>
<tbody>
{contacts.map((c) => {
const isSel = selected.includes(c.id);
const name =
(c.first_name || c.last_name)
? `${c.first_name ?? ""} ${c.last_name ?? ""}`.trim()
: c.email;
return (
<tr
key={c.id}
onClick={() => onRowClick(c.id)}
className={`group h-11 transition-colors cursor-pointer border-b border-slate-200/60 ${
isSel ? "bg-sky-50/60" : "hover:bg-slate-50/80"
}`}
>
<td
className="pl-5 pr-2"
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
className="w-3.5 h-3.5 rounded accent-sky-600"
checked={isSel}
onChange={() => onToggle(c.id, !isSel)}
/>
</td>
<td className="px-3">
<div className="flex items-center gap-2.5 min-w-0">
<div className="w-6 h-6 rounded-full bg-slate-100 flex items-center justify-center shrink-0">
<span className="text-[9.5px] font-semibold text-slate-600">
{(c.first_name || c.email)?.slice(0, 2).toUpperCase()}
</span>
</div>
<div className="min-w-0">
<div className="text-[12.5px] text-slate-900 font-medium truncate leading-tight">
{name}
</div>
<div className="text-[10.5px] text-slate-400 truncate font-mono leading-tight flex items-center gap-1">
<MailIcon className="w-2.5 h-2.5" />
{c.email}
</div>
</div>
</div>
</td>
<td className="px-3 text-[12px] text-slate-600 truncate hidden md:table-cell">
{c.company ? (
<span className="inline-flex items-center gap-1.5">
<Building2Icon className="w-3 h-3 text-slate-400" />
{c.company}
</span>
) : (
<span className="text-slate-300"></span>
)}
</td>
<td className="px-3 text-[12px] text-slate-600 truncate hidden lg:table-cell font-mono">
{c.phone ? (
<span className="inline-flex items-center gap-1.5">
<PhoneIcon className="w-3 h-3 text-slate-400" />
{c.phone}
</span>
) : (
<span className="text-slate-300"></span>
)}
</td>
<td className="px-3">
<StatusPill subscribed={c.subscribed} />
</td>
<td className="px-3 text-right font-mono text-[12px] text-slate-600 tabular-nums">
{c.campaigns?.length ?? 0}
</td>
<td className="px-3 text-right font-mono text-[11px] text-slate-500 tabular-nums hidden md:table-cell">
{c.created_at
? new Date(c.created_at).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})
: "—"}
</td>
<td className="px-3" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
aria-label="Delete contact"
onClick={() => onDelete(c.id)}
className="size-6 rounded text-slate-400 hover:text-red-600 hover:bg-red-50 flex items-center justify-center transition-colors"
>
<TrashIcon className="w-3 h-3" />
</button>
<button
type="button"
aria-label="More"
onClick={() => onRowClick(c.id)}
className="size-6 rounded text-slate-400 hover:text-slate-900 hover:bg-slate-100 flex items-center justify-center transition-colors"
>
<MoreHorizontalIcon className="w-3 h-3" />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
{hasNextPage && (
<div className="px-5 py-3 flex justify-center border-t border-slate-200/60">
<button
onClick={onLoadMore}
disabled={isFetchingNextPage}
className="h-7 px-3 rounded-md border border-slate-200 hover:border-slate-300 text-[12px] text-slate-700 hover:text-slate-900 inline-flex items-center gap-1.5 transition-colors disabled:opacity-50"
>
{isFetchingNextPage ? (
<>
<Loader2Icon className="w-3 h-3 animate-spin" />
Loading
</>
) : (
<>
<PlusIcon className="w-3 h-3" />
Load more
</>
)}
</button>
</div>
)}
</>
);
}
function Th({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<th
className={`px-3 py-2 text-[10px] font-medium text-slate-400 uppercase tracking-[0.14em] ${className ?? ""}`}
>
{children}
</th>
);
}
function StatusPill({ subscribed }: { subscribed: boolean }) {
if (subscribed) {
return (
<span className="inline-flex items-center gap-1 text-[10.5px] font-medium text-emerald-700 uppercase tracking-[0.08em]">
<span className="size-1.5 rounded-full bg-emerald-500" />
subscribed
</span>
);
}
return (
<span className="inline-flex items-center gap-1 text-[10.5px] font-medium text-slate-500 uppercase tracking-[0.08em]">
<span className="size-1.5 rounded-full bg-slate-300" />
unsubscribed
</span>
);
}
function SelectionBar({
count,
deleting,
onBulkEdit,
onDelete,
onClear,
}: {
count: number;
deleting: boolean;
onBulkEdit: () => void;
onDelete: () => void;
onClear: () => void;
}) {
if (count === 0) return null;
return (
<div className="absolute bottom-3 left-1/2 -translate-x-1/2 z-10 flex items-center gap-1.5 rounded-md border border-slate-200 bg-white shadow-[0_6px_20px_-4px_rgba(15,23,42,0.12),0_2px_4px_rgba(15,23,42,0.04)] px-2 py-1.5">
<div className="inline-flex items-center gap-1.5 px-2 h-7 rounded bg-sky-50 text-sky-700 text-[12px] font-medium">
<CheckIcon className="w-3 h-3" />
<span>{count} selected</span>
</div>
<button
type="button"
onClick={onBulkEdit}
className="h-7 px-2.5 rounded text-[12px] text-slate-700 hover:text-slate-900 hover:bg-slate-100 font-medium transition-colors"
>
Edit
</button>
<button
type="button"
onClick={onDelete}
disabled={deleting}
className="h-7 px-2.5 rounded text-[12px] text-red-600 hover:text-white hover:bg-red-600 font-medium inline-flex items-center gap-1.5 transition-colors disabled:opacity-60"
>
{deleting ? <Loader2Icon className="w-3 h-3 animate-spin" /> : <TrashIcon className="w-3 h-3" />}
Delete
</button>
<div className="h-4 w-px bg-slate-200" />
<button
type="button"
onClick={onClear}
className="h-7 px-2.5 rounded text-[12px] text-slate-500 hover:text-slate-900 transition-colors"
>
Clear
</button>
</div>
);
}
@@ -1,40 +1,89 @@
import type UniboxEmail from '@/lib/api/models/app/unibox/UniboxEmail'
import { useAppStore } from '@/stores'
import { cn } from '@/lib/utils'
import type UniboxEmail from "@/lib/api/models/app/unibox/UniboxEmail";
import { useAppStore } from "@/stores";
import { cn } from "@/lib/utils";
function relative(d: Date): string {
const diff = Date.now() - d.getTime();
const m = Math.floor(diff / 60_000);
if (m < 1) return "now";
if (m < 60) return `${m}m`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h`;
const days = Math.floor(h / 24);
if (days < 7) return `${days}d`;
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
function fromName(s: string): string {
const m = s.match(/^"?([^"<]+)"?\s*<.+>$/);
if (m) return m[1].trim();
return s.replace(/<.+>/, "").trim() || s;
}
function initials(s: string): string {
const name = fromName(s);
const parts = name.split(/\s+/).filter(Boolean);
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
return (parts[0]?.slice(0, 2) ?? "??").toUpperCase();
}
interface ConversationItemProps {
email: UniboxEmail
email: UniboxEmail;
}
export function ConversationItem({ email }: ConversationItemProps) {
const selectedThreadId = useAppStore((s) => s.selectedThreadId)
const setSelectedThreadId = useAppStore((s) => s.setSelectedThreadId)
const selectedThreadId = useAppStore((s) => s.selectedThreadId);
const setSelectedThreadId = useAppStore((s) => s.setSelectedThreadId);
const threadId = email.thread_id || email.id
const isSelected = selectedThreadId === threadId
const date = new Date(email.date)
const timeStr = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
const threadId = email.thread_id || email.id;
const isSelected = selectedThreadId === threadId;
const date = new Date(email.date);
const unread = !email.is_seen;
const preview = email.body.replace(/<[^>]*>/g, "").replace(/\s+/g, " ").slice(0, 100);
return (
<button
onClick={() => setSelectedThreadId(threadId)}
className={cn(
'w-full text-left p-3 border-b border-border hover:bg-accent/50 transition-colors',
isSelected && 'bg-accent border-l-3 border-l-primary',
!email.is_seen && 'font-medium'
)}
>
<div className="flex items-start justify-between gap-2">
<span className="text-sm truncate flex-1">{email.from}</span>
<span className="text-xs text-muted-foreground shrink-0">{timeStr}</span>
</div>
<p className="text-sm truncate mt-0.5">{email.subject}</p>
<p className="text-xs text-muted-foreground truncate mt-0.5">
{email.body.replace(/<[^>]*>/g, '').slice(0, 80)}
</p>
{!email.is_seen && (
<span className="inline-block size-2 bg-primary mt-1" />
)}
</button>
)
return (
<button
onClick={() => setSelectedThreadId(threadId)}
className={cn(
"group w-full text-left px-3 py-2.5 transition-colors flex items-start gap-2.5 relative",
isSelected ? "bg-sky-50/80" : "hover:bg-slate-50/80",
)}
>
{unread && (
<span
aria-hidden
className="absolute left-0 top-2.5 bottom-2.5 w-[3px] rounded-r bg-sky-500"
/>
)}
<div
className={cn(
"size-7 rounded-full flex items-center justify-center shrink-0 text-[10px] font-semibold",
isSelected ? "bg-sky-100 text-sky-700" : "bg-slate-100 text-slate-600",
)}
>
{initials(email.from)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<span
className={cn(
"text-[12.5px] truncate",
unread ? "text-slate-900 font-semibold" : "text-slate-800 font-medium",
)}
>
{fromName(email.from)}
</span>
<span className="font-mono text-[10px] text-slate-400 tabular-nums shrink-0">
{relative(date)}
</span>
</div>
<div className="text-[12px] text-slate-700 truncate mt-0.5">
{email.subject || "(no subject)"}
</div>
<div className="text-[11px] text-slate-400 truncate mt-0.5">
{preview}
</div>
</div>
</button>
);
}
@@ -1,52 +1,101 @@
import { useState } from 'react'
import { Input } from '@/components/ui/input'
import { ConversationItem } from './ConversationItem'
import { UniboxFilters } from './UniboxFilters'
import { useAppStore } from '@/stores'
import { SearchIcon } from 'lucide-react'
// Conversation list — middle pane of the unibox.
//
// Rows show from / subject / preview / time. Unread rows get a small
// sky rail on the left margin and a bolder from-name.
import { useState } from "react";
import { useAppStore } from "@/stores";
import { SearchInput } from "@/components/ui/field";
import { ConversationItem } from "./ConversationItem";
import { SectionBar } from "@/components/layout/Page";
type Filter = "all" | "unread";
const FILTERS: Array<{ id: Filter; label: string }> = [
{ id: "all", label: "All" },
{ id: "unread", label: "Unread" },
];
export function ConversationList() {
const [search, setSearch] = useState('')
const [filter, setFilter] = useState<'all' | 'unread'>('all')
const emails = useAppStore((s) => s.uniboxEmails)
const [search, setSearch] = useState("");
const [filter, setFilter] = useState<Filter>("all");
const emails = useAppStore((s) => s.uniboxEmails);
const filtered = emails.filter((email) => {
if (filter === 'unread' && email.is_seen) return false
if (search) {
const q = search.toLowerCase()
return (
email.subject.toLowerCase().includes(q) ||
email.from.toLowerCase().includes(q)
)
}
return true
})
const filtered = emails.filter((email) => {
if (filter === "unread" && email.is_seen) return false;
if (search) {
const q = search.toLowerCase();
return (
email.subject.toLowerCase().includes(q) ||
email.from.toLowerCase().includes(q)
);
}
return true;
});
return (
<>
<div className="p-3 border-b-2 space-y-2">
<div className="relative">
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
<Input
placeholder="Search conversations..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8 h-8 text-sm"
/>
const unreadCount = emails.filter((e) => !e.is_seen).length;
return (
<div className="flex flex-col h-full">
<SectionBar label="Inbox" count={filtered.length} />
<div className="px-3 py-2 shrink-0 border-b border-slate-200">
<SearchInput
value={search}
onChange={setSearch}
placeholder="Search conversations…"
/>
<div className="flex items-center gap-0.5 mt-2">
{FILTERS.map((f) => (
<button
key={f.id}
onClick={() => setFilter(f.id)}
className={`h-6 px-2 rounded text-[11.5px] font-medium transition-colors inline-flex items-center gap-1 ${
filter === f.id
? "bg-slate-900 text-white"
: "text-slate-500 hover:text-slate-900 hover:bg-slate-100"
}`}
>
{f.label}
{f.id === "unread" && unreadCount > 0 && (
<span
className={`font-mono tabular-nums text-[10px] ${
filter === f.id ? "text-white/80" : "text-sky-600"
}`}
>
{unreadCount}
</span>
)}
</button>
))}
</div>
</div>
<div className="flex-1 overflow-y-auto">
{filtered.length === 0 ? (
<div className="px-5 py-16 text-center">
<p className="text-[12.5px] text-slate-700 font-medium mb-1">
{search
? "No matches"
: filter === "unread"
? "All caught up"
: "No conversations yet"}
</p>
<p className="text-[11.5px] text-slate-400 max-w-[28ch] mx-auto leading-relaxed">
{search
? "Try a different keyword."
: filter === "unread"
? "When new mail arrives it'll show up here."
: "Replies and inbound mail land here automatically."}
</p>
</div>
) : (
<div className="divide-y divide-slate-200/60">
{filtered.map((email) => (
<ConversationItem key={email.id} email={email} />
))}
</div>
)}
</div>
</div>
<UniboxFilters value={filter} onChange={setFilter} />
</div>
<div className="flex-1 overflow-y-auto">
{filtered.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground">
No conversations found
</div>
) : (
filtered.map((email) => (
<ConversationItem key={email.id} email={email} />
))
)}
</div>
</>
)
);
}
+68 -27
View File
@@ -1,34 +1,75 @@
import type UniboxEmail from '@/lib/api/models/app/unibox/UniboxEmail'
import { Card, CardContent } from '@/components/ui/card'
// Single message in a thread.
//
// Header row holds sender (avatar + name + email), recipient(s), and
// timestamp. Body sits below in regular prose with light styling — no
// containing card, just hairlines between messages.
import type UniboxEmail from "@/lib/api/models/app/unibox/UniboxEmail";
interface MessageBubbleProps {
email: UniboxEmail
email: UniboxEmail;
}
function fromName(s: string): string {
const m = s.match(/^"?([^"<]+)"?\s*<.+>$/);
if (m) return m[1].trim();
return s.replace(/<.+>/, "").trim() || s;
}
function fromAddr(s: string): string | null {
const m = s.match(/<([^>]+)>/);
if (m) return m[1].trim();
return null;
}
function initials(s: string): string {
const name = fromName(s);
const parts = name.split(/\s+/).filter(Boolean);
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
return (parts[0]?.slice(0, 2) ?? "??").toUpperCase();
}
export function MessageBubble({ email }: MessageBubbleProps) {
const date = new Date(email.date)
const timeStr = date.toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
const date = new Date(email.date);
const dateStr = date.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
return (
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between mb-2">
<div>
<span className="text-sm font-medium">{email.from}</span>
<span className="text-xs text-muted-foreground ml-2">to {email.to}</span>
</div>
<span className="text-xs text-muted-foreground">{timeStr}</span>
</div>
<div
className="text-sm prose prose-sm max-w-none"
dangerouslySetInnerHTML={{ __html: email.body }}
/>
</CardContent>
</Card>
)
const name = fromName(email.from);
const addr = fromAddr(email.from);
return (
<article className="px-5 py-4">
<header className="flex items-start gap-3 mb-3">
<div className="size-7 rounded-full bg-slate-100 text-slate-600 flex items-center justify-center text-[10px] font-semibold shrink-0">
{initials(email.from)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<span className="text-[12.5px] font-semibold text-slate-900 truncate">
{name}
</span>
{addr && (
<span className="font-mono text-[10.5px] text-slate-400 truncate">
{addr}
</span>
)}
</div>
<div className="text-[11px] text-slate-500 mt-0.5 flex items-center gap-1.5">
<span>to {email.to}</span>
</div>
</div>
<span className="font-mono text-[10.5px] text-slate-400 tabular-nums shrink-0">
{dateStr}
</span>
</header>
<div
className="text-[13px] text-slate-800 leading-relaxed prose prose-sm max-w-none prose-p:my-2 prose-a:text-sky-600"
dangerouslySetInnerHTML={{ __html: email.body }}
/>
</article>
);
}
+114 -59
View File
@@ -1,71 +1,126 @@
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { SendIcon } from 'lucide-react'
import toast from 'react-hot-toast'
import sendReply from '@/lib/api/client/app/unibox/sendReply'
import type UniboxEmail from '@/lib/api/models/app/unibox/UniboxEmail'
// Reply composer — pinned to the bottom of the thread pane.
//
// Slim chrome: a textarea with a hairline border on top of an action
// bar (send + cancel + schedule placeholder). ⌘+Enter sends; Esc
// clears focus. Reads as a quick reply, not a full editor.
import { useState } from "react";
import { ChevronDownIcon, SendIcon } from "lucide-react";
import toast from "react-hot-toast";
import sendReply from "@/lib/api/client/app/unibox/sendReply";
import type UniboxEmail from "@/lib/api/models/app/unibox/UniboxEmail";
import {
PopoverMenu,
PopoverMenuContent,
PopoverMenuItem,
PopoverMenuLabel,
PopoverMenuTrigger,
} from "@/components/ui/popover-menu";
interface ReplyComposerProps {
threadId: string
threadEmails: UniboxEmail[]
threadId: string;
threadEmails: UniboxEmail[];
}
export function ReplyComposer({ threadId, threadEmails }: ReplyComposerProps) {
const [reply, setReply] = useState('')
const [isSending, setIsSending] = useState(false)
const [reply, setReply] = useState("");
const [isSending, setIsSending] = useState(false);
const handleSend = async () => {
if (!reply.trim()) return
const handleSend = async () => {
if (!reply.trim()) return;
const latestEmail = threadEmails[threadEmails.length - 1]
if (!latestEmail?.account_id) {
toast.error('Cannot determine sender account for this thread')
return
}
const latestEmail = threadEmails[threadEmails.length - 1];
if (!latestEmail?.account_id) {
toast.error("Cannot determine sender account for this thread");
return;
}
const replyTo = latestEmail.from?.trim()
if (!replyTo) {
toast.error('Cannot determine recipient for this reply')
return
}
const replyTo = latestEmail.from?.trim();
if (!replyTo) {
toast.error("Cannot determine recipient for this reply");
return;
}
const subjectBase = latestEmail.subject?.trim() || 'Re:'
const subject = /^re:/i.test(subjectBase) ? subjectBase : `Re: ${subjectBase}`
const subjectBase = latestEmail.subject?.trim() || "Re:";
const subject = /^re:/i.test(subjectBase) ? subjectBase : `Re: ${subjectBase}`;
setIsSending(true)
try {
await sendReply({
email_account_id: latestEmail.account_id,
to: [replyTo],
subject,
body_plain: reply.trim(),
body_html: reply.trim().replace(/\n/g, '<br />'),
thread_id: threadId,
send_mode: 'instant',
})
setReply('')
toast.success('Reply queued')
} catch {
toast.error('Failed to send reply')
} finally {
setIsSending(false)
}
}
setIsSending(true);
try {
await sendReply({
email_account_id: latestEmail.account_id,
to: [replyTo],
subject,
body_plain: reply.trim(),
body_html: reply.trim().replace(/\n/g, "<br />"),
thread_id: threadId,
send_mode: "instant",
});
setReply("");
toast.success("Reply queued");
} catch {
toast.error("Failed to send reply");
} finally {
setIsSending(false);
}
};
return (
<div className="p-4 border-t-2">
<textarea
value={reply}
onChange={(e) => setReply(e.target.value)}
placeholder="Type your reply..."
className="w-full min-h-[80px] border-2 border-input bg-transparent p-3 text-sm resize-none focus:outline-none focus:border-ring"
/>
<div className="flex justify-end mt-2">
<Button size="sm" onClick={handleSend} disabled={!reply.trim() || isSending}>
<SendIcon className="size-3.5" />
{isSending ? 'Sending...' : 'Send Reply'}
</Button>
</div>
</div>
)
return (
<div className="border-t border-slate-200 bg-white shrink-0">
<textarea
value={reply}
onChange={(e) => setReply(e.target.value)}
placeholder="Type a reply… (⌘ + Enter to send)"
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
handleSend();
}
}}
className="w-full min-h-[80px] max-h-60 px-5 py-3 text-[13px] text-slate-800 placeholder:text-slate-400 bg-transparent resize-y focus:outline-none"
/>
<div className="px-3 py-2 border-t border-slate-200/60 flex items-center gap-1.5">
<button
type="button"
onClick={handleSend}
disabled={!reply.trim() || isSending}
className="h-7 px-2.5 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<SendIcon className="w-3 h-3" />
{isSending ? "Sending…" : "Send"}
</button>
<PopoverMenu align="start" side="top">
<PopoverMenuTrigger asChild>
<button
type="button"
className="h-7 px-2 rounded-md border border-slate-200 hover:border-slate-300 text-slate-700 hover:text-slate-900 text-[12px] inline-flex items-center gap-1 transition-colors"
>
Schedule
<ChevronDownIcon className="w-3 h-3 text-slate-400" />
</button>
</PopoverMenuTrigger>
<PopoverMenuContent>
<PopoverMenuLabel>Send at</PopoverMenuLabel>
<PopoverMenuItem>In 1 hour</PopoverMenuItem>
<PopoverMenuItem>Tomorrow 9:00</PopoverMenuItem>
<PopoverMenuItem>Next Monday 9:00</PopoverMenuItem>
</PopoverMenuContent>
</PopoverMenu>
{reply && (
<button
type="button"
onClick={() => setReply("")}
className="h-7 px-2 rounded-md text-slate-500 hover:text-slate-900 text-[12px] transition-colors"
>
Discard
</button>
)}
<span className="ml-auto font-mono text-[10px] text-slate-400 tabular-nums">
{reply.length}/{4000}
</span>
</div>
</div>
);
}
+70 -31
View File
@@ -1,41 +1,80 @@
import { MessageBubble } from './MessageBubble'
import { ReplyComposer } from './ReplyComposer'
import { useAppStore } from '@/stores'
// Thread view — right pane of the unibox.
//
// Top: subject + meta + actions. Body: message stream — each message is
// a clean block with hairline header, no card containers. Bottom: pinned
// ReplyComposer.
import { MessageBubble } from "./MessageBubble";
import { ReplyComposer } from "./ReplyComposer";
import { useAppStore } from "@/stores";
import { ArchiveIcon, MailCheckIcon, TrashIcon } from "lucide-react";
import { SectionBar } from "@/components/layout/Page";
interface ThreadViewProps {
threadId: string
threadId: string;
}
export function ThreadView({ threadId }: ThreadViewProps) {
const emails = useAppStore((s) => s.uniboxEmails)
const threadEmails = emails
.filter((e) => e.thread_id === threadId || e.id === threadId)
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
const emails = useAppStore((s) => s.uniboxEmails);
const threadEmails = emails
.filter((e) => e.thread_id === threadId || e.id === threadId)
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
if (threadEmails.length === 0) {
return (
<div className="flex-1 flex items-center justify-center text-[12px] text-slate-400">
Loading thread
</div>
);
}
const subject = threadEmails[0]?.subject || "(no subject)";
const participants = new Set(threadEmails.map((e) => e.from));
if (threadEmails.length === 0) {
return (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<p>Loading thread...</p>
</div>
)
}
<div className="flex flex-col h-full bg-white">
<div className="h-12 px-5 border-b border-slate-200 flex items-center gap-3 shrink-0 bg-white">
<span className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">
Thread
</span>
<div className="h-4 w-px bg-slate-200" />
<span className="text-[12.5px] text-slate-900 font-medium truncate">
{subject}
</span>
<div className="ml-auto flex items-center gap-1">
<button
aria-label="Mark unread"
className="size-7 rounded-md text-slate-500 hover:text-slate-900 hover:bg-slate-100 inline-flex items-center justify-center transition-colors"
>
<MailCheckIcon className="w-3.5 h-3.5" />
</button>
<button
aria-label="Archive"
className="size-7 rounded-md text-slate-500 hover:text-slate-900 hover:bg-slate-100 inline-flex items-center justify-center transition-colors"
>
<ArchiveIcon className="w-3.5 h-3.5" />
</button>
<button
aria-label="Delete"
className="size-7 rounded-md text-slate-500 hover:text-red-600 hover:bg-red-50 inline-flex items-center justify-center transition-colors"
>
<TrashIcon className="w-3.5 h-3.5" />
</button>
</div>
</div>
const subject = threadEmails[0]?.subject || 'No Subject'
<SectionBar
label={`${threadEmails.length} ${threadEmails.length === 1 ? "message" : "messages"}`}
count={participants.size}
/>
return (
<div className="flex flex-col h-full">
<div className="p-4 border-b-2">
<h2 className="text-lg font-semibold">{subject}</h2>
<p className="text-sm text-muted-foreground">
{threadEmails.length} message{threadEmails.length !== 1 ? 's' : ''}
</p>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{threadEmails.map((email) => (
<MessageBubble key={email.id} email={email} />
))}
</div>
<ReplyComposer threadId={threadId} threadEmails={threadEmails} />
</div>
)
<div className="flex-1 overflow-y-auto divide-y divide-slate-200/60">
{threadEmails.map((email) => (
<MessageBubble key={email.id} email={email} />
))}
</div>
<ReplyComposer threadId={threadId} threadEmails={threadEmails} />
</div>
);
}
+4 -1
View File
@@ -19,6 +19,7 @@ import { Outlet } from "react-router-dom";
import { SkyChrome } from "./SkyChrome";
import { AppHeader } from "./AppHeader";
import { AppNav } from "./AppNav";
import { RouteBoundary } from "./ErrorBoundary";
import { ShortcutsModal } from "@/components/shared/ShortcutsModal";
import { CommandPalette } from "@/components/shared/CommandPalette";
import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts";
@@ -44,7 +45,9 @@ export function AppShell() {
defines the panel without a heavy shadow. */}
<main className="flex-1 min-w-0 rounded-tl-2xl bg-white overflow-hidden border-t border-l border-slate-200/70">
<div className="h-full overflow-auto">
<Outlet />
<RouteBoundary>
<Outlet />
</RouteBoundary>
</div>
</main>
</div>
+117
View File
@@ -0,0 +1,117 @@
// Route-level error boundary.
//
// Without this, an unhandled render error in any page unmounts the entire
// subtree to the next React boundary (which is "none" in this app), so the
// content panel goes silent-white with no signal about what broke. This
// boundary catches it, prints the actual error inside the panel using the
// same brae-density chrome as the rest of the app, and offers a retry.
//
// Wrap each route element with <RouteBoundary>...</RouteBoundary> or use
// the <withBoundary> helper to opt a page in.
import React from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { AlertTriangleIcon, RefreshCcwIcon } from "lucide-react";
interface State {
error: Error | null;
info: React.ErrorInfo | null;
}
export class ErrorBoundary extends React.Component<
{ children: React.ReactNode; onReset?: () => void },
State
> {
state: State = { error: null, info: null };
static getDerivedStateFromError(error: Error): Partial<State> {
return { error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
this.setState({ info });
if (typeof window !== "undefined" && (window as unknown as { Sentry?: { captureException: (e: Error) => void } }).Sentry) {
(window as unknown as { Sentry?: { captureException: (e: Error) => void } }).Sentry?.captureException(error);
}
console.error("[ErrorBoundary]", error, info?.componentStack);
}
reset = () => {
this.setState({ error: null, info: null });
this.props.onReset?.();
};
render() {
if (!this.state.error) return this.props.children;
return <BoundaryFallback error={this.state.error} info={this.state.info} reset={this.reset} />;
}
}
function BoundaryFallback({ error, info, reset }: { error: Error; info: React.ErrorInfo | null; reset: () => void }) {
const navigate = useNavigate();
return (
<div className="flex flex-col min-h-full bg-white">
<div className="h-12 px-5 border-b border-slate-200 flex items-center gap-3 shrink-0 bg-white">
<span className="text-[10px] uppercase tracking-[0.14em] text-red-500 font-medium">
Page error
</span>
<div className="h-4 w-px bg-slate-200" />
<span className="text-[12.5px] text-slate-600 truncate">
{error.message || "Something broke while rendering this page"}
</span>
<div className="ml-auto flex items-center gap-1.5">
<button
onClick={() => navigate(-1)}
className="h-7 px-2.5 rounded-md border border-slate-200 hover:border-slate-300 text-slate-700 hover:text-slate-900 text-[12px] font-medium transition-colors"
>
Back
</button>
<button
onClick={reset}
className="h-7 px-2.5 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors"
>
<RefreshCcwIcon className="w-3 h-3" />
Retry
</button>
</div>
</div>
<div className="flex-1 min-h-0 overflow-auto px-5 py-6">
<div className="max-w-3xl">
<div className="flex items-center gap-2 mb-3">
<AlertTriangleIcon className="w-3.5 h-3.5 text-red-500 shrink-0" />
<span className="text-[12.5px] font-semibold text-slate-900">
{error.name || "Error"}
</span>
</div>
<p className="text-[12px] text-slate-700 mb-4 leading-relaxed">
{error.message || "No message provided."}
</p>
{(error.stack || info?.componentStack) && (
<details className="border border-slate-200 rounded-md bg-slate-50 overflow-hidden">
<summary className="px-3 py-2 text-[11px] font-medium text-slate-700 cursor-pointer hover:bg-slate-100 transition-colors">
Stack
</summary>
<pre className="px-3 py-3 text-[10.5px] font-mono text-slate-700 leading-relaxed overflow-x-auto whitespace-pre-wrap border-t border-slate-200">
{error.stack || ""}
{info?.componentStack ? `\n\nComponent stack:${info.componentStack}` : ""}
</pre>
</details>
)}
</div>
</div>
</div>
);
}
/**
* RouteBoundary — react-router compatible: resets the boundary on
* pathname change so navigating away from a broken page recovers
* automatically.
*/
export function RouteBoundary({ children }: { children: React.ReactNode }) {
const { pathname } = useLocation();
// Key forces a fresh ErrorBoundary instance on every route change, which
// both clears stale errors and lets the new page mount cleanly.
return <ErrorBoundary key={pathname}>{children}</ErrorBoundary>;
}
+116
View File
@@ -0,0 +1,116 @@
// Form field primitives — slim, brae-density.
//
// Replaces the half-dozen ad-hoc inputs across pages. Two main pieces:
//
// <SearchInput value={q} onChange={setQ} placeholder="Search…" />
// <TextInput value={x} onChange={setX} placeholder="Domain" />
//
// All 28px tall, hairline border, 12.5px text, 12px horizontal padding,
// focus ring tuned to sky-200 so it blends with the rest of the chrome.
import React from "react";
import { SearchIcon, XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
const base =
"h-7 px-2.5 rounded-md border border-slate-200 bg-white text-[12.5px] text-slate-900 placeholder:text-slate-400 outline-none transition-colors focus:border-sky-400 focus:ring-2 focus:ring-sky-100 disabled:bg-slate-50 disabled:text-slate-400";
export function TextInput({
value,
onChange,
placeholder,
type = "text",
disabled,
autoFocus,
className,
onKeyDown,
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
type?: string;
disabled?: boolean;
autoFocus?: boolean;
className?: string;
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
}) {
return (
<input
type={type}
value={value}
placeholder={placeholder}
disabled={disabled}
autoFocus={autoFocus}
onChange={(e) => onChange(e.target.value)}
onKeyDown={onKeyDown}
className={cn(base, "min-w-0", className)}
/>
);
}
export function SearchInput({
value,
onChange,
placeholder = "Search…",
autoFocus,
className,
onKeyDown,
onSubmit,
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
autoFocus?: boolean;
className?: string;
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
onSubmit?: (v: string) => void;
}) {
return (
<div className={cn(
"h-7 pl-2 pr-1 rounded-md border border-slate-200 bg-white flex items-center gap-1.5 focus-within:border-sky-400 focus-within:ring-2 focus-within:ring-sky-100 transition-colors min-w-0",
className,
)}>
<SearchIcon className="w-3.5 h-3.5 text-slate-400 shrink-0" />
<input
value={value}
placeholder={placeholder}
autoFocus={autoFocus}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") onSubmit?.(value);
onKeyDown?.(e);
}}
className="flex-1 min-w-0 h-full bg-transparent outline-none text-[12.5px] text-slate-900 placeholder:text-slate-400"
/>
{value && (
<button
type="button"
onClick={() => onChange("")}
aria-label="Clear search"
className="size-5 rounded text-slate-400 hover:text-slate-700 hover:bg-slate-100 flex items-center justify-center shrink-0 transition-colors"
>
<XIcon className="w-3 h-3" />
</button>
)}
</div>
);
}
export function Label({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<label className={cn(
"text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium block mb-1.5",
className,
)}>
{children}
</label>
);
}
export function FieldRow({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<div className={cn("space-y-1.5", className)}>
{children}
</div>
);
}
+336
View File
@@ -0,0 +1,336 @@
// Brae-density dropdown menu.
//
// Drop-in replacement for the legacy HeadSelectMenu and the shadcn
// DropdownMenu where we want the slim look used across the new chrome:
// 28px trigger, hairline-border surface, h-7 items, mono accents.
//
// Composition:
//
// <PopoverMenu>
// <PopoverMenuTrigger>
// <button>Trigger</button>
// </PopoverMenuTrigger>
// <PopoverMenuContent>
// <PopoverMenuLabel>Section</PopoverMenuLabel>
// <PopoverMenuItem onSelect={...} icon={...} selected>
// Acme
// <PopoverMenuKbd>⌘1</PopoverMenuKbd>
// </PopoverMenuItem>
// <PopoverMenuSeparator />
// <PopoverMenuItem icon={<PlusIcon className="w-3 h-3" />}>
// New workspace
// </PopoverMenuItem>
// </PopoverMenuContent>
// </PopoverMenu>
//
// Implementation notes:
// - Built on Radix-style refs for trigger/content, but with our own
// click-outside + Esc handling instead of dragging in the full
// primitive surface. Keeps the bundle small and the styles
// authoritative (no defaults to override).
// - Positions itself underneath the trigger by default; pass
// `align="end"` to right-align, `side="top"` to flip above.
import React, {
createContext,
useCallback,
useContext,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils";
interface MenuCtx {
id: string;
open: boolean;
setOpen: (o: boolean) => void;
triggerRef: React.RefObject<HTMLElement>;
side: "bottom" | "top";
align: "start" | "end" | "center";
sideOffset: number;
}
const Ctx = createContext<MenuCtx | null>(null);
function useMenu() {
const c = useContext(Ctx);
if (!c) throw new Error("PopoverMenu primitives must be used inside <PopoverMenu>");
return c;
}
export function PopoverMenu({
children,
side = "bottom",
align = "start",
sideOffset = 6,
open: controlledOpen,
onOpenChange,
}: {
children: React.ReactNode;
side?: "bottom" | "top";
align?: "start" | "end" | "center";
sideOffset?: number;
open?: boolean;
onOpenChange?: (o: boolean) => void;
}) {
const id = useId();
const triggerRef = useRef<HTMLElement>(null);
const [internalOpen, setInternalOpen] = useState(false);
const open = controlledOpen ?? internalOpen;
const setOpen = useCallback(
(o: boolean) => {
if (controlledOpen === undefined) setInternalOpen(o);
onOpenChange?.(o);
},
[controlledOpen, onOpenChange],
);
return (
<Ctx.Provider value={{ id, open, setOpen, triggerRef, side, align, sideOffset }}>
{children}
</Ctx.Provider>
);
}
export function PopoverMenuTrigger({
children,
asChild = false,
}: {
children: React.ReactNode;
asChild?: boolean;
}) {
const { open, setOpen, triggerRef } = useMenu();
const onClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setOpen(!open);
};
if (asChild && React.isValidElement(children)) {
return React.cloneElement(children as React.ReactElement<{
ref?: React.Ref<HTMLElement>;
onClick?: (e: React.MouseEvent) => void;
"aria-expanded"?: boolean;
"data-state"?: "open" | "closed";
}>, {
ref: triggerRef,
onClick,
"aria-expanded": open,
"data-state": open ? "open" : "closed",
});
}
return (
<button
ref={triggerRef as React.RefObject<HTMLButtonElement>}
type="button"
onClick={onClick}
aria-expanded={open}
data-state={open ? "open" : "closed"}
>
{children}
</button>
);
}
export function PopoverMenuContent({
children,
className,
minWidth = 200,
}: {
children: React.ReactNode;
className?: string;
minWidth?: number;
}) {
const { open, setOpen, triggerRef, side, align, sideOffset } = useMenu();
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ top: number; left: number; width?: number } | null>(null);
useLayoutEffect(() => {
if (!open) {
setPos(null);
return;
}
const compute = () => {
const t = triggerRef.current;
const c = ref.current;
if (!t || !c) return;
const r = t.getBoundingClientRect();
const cw = c.offsetWidth;
const ch = c.offsetHeight;
let top: number;
if (side === "bottom") {
top = r.bottom + sideOffset;
if (top + ch > window.innerHeight - 8) top = r.top - ch - sideOffset;
} else {
top = r.top - ch - sideOffset;
if (top < 8) top = r.bottom + sideOffset;
}
let left: number;
if (align === "end") left = r.right - cw;
else if (align === "center") left = r.left + r.width / 2 - cw / 2;
else left = r.left;
if (left + cw > window.innerWidth - 8) left = window.innerWidth - 8 - cw;
if (left < 8) left = 8;
setPos({ top, left, width: r.width });
};
compute();
window.addEventListener("resize", compute);
window.addEventListener("scroll", compute, true);
return () => {
window.removeEventListener("resize", compute);
window.removeEventListener("scroll", compute, true);
};
}, [open, side, align, sideOffset, triggerRef]);
useEffect(() => {
if (!open) return;
const onClick = (e: MouseEvent) => {
const t = e.target as Node;
if (ref.current?.contains(t)) return;
if (triggerRef.current?.contains(t)) return;
setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onClick);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onClick);
document.removeEventListener("keydown", onKey);
};
}, [open, setOpen, triggerRef]);
if (!open) return null;
return (
<div
ref={ref}
role="menu"
style={{
position: "fixed",
top: pos?.top ?? -9999,
left: pos?.left ?? -9999,
minWidth,
visibility: pos ? "visible" : "hidden",
zIndex: 100,
}}
className={cn(
"rounded-md border border-slate-200 bg-white shadow-[0_4px_12px_-2px_rgba(15,23,42,0.08),0_2px_4px_rgba(15,23,42,0.04)] overflow-hidden py-1",
className,
)}
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
);
}
export function PopoverMenuLabel({ children }: { children: React.ReactNode }) {
return (
<div className="px-3 pt-2 pb-1 text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">
{children}
</div>
);
}
export function PopoverMenuItem({
children,
onSelect,
icon,
selected = false,
danger = false,
disabled = false,
closeOnSelect = true,
}: {
children: React.ReactNode;
onSelect?: () => void;
icon?: React.ReactNode;
selected?: boolean;
danger?: boolean;
disabled?: boolean;
closeOnSelect?: boolean;
}) {
const { setOpen } = useMenu();
return (
<button
type="button"
disabled={disabled}
role="menuitem"
onClick={(e) => {
e.stopPropagation();
if (disabled) return;
onSelect?.();
if (closeOnSelect) setOpen(false);
}}
className={cn(
"w-full h-7 px-3 flex items-center gap-2 text-[12.5px] text-left transition-colors",
danger
? "text-red-600 hover:bg-red-50"
: "text-slate-700 hover:bg-slate-50 hover:text-slate-900",
selected && !danger && "text-slate-900 font-medium",
disabled && "opacity-50 cursor-not-allowed",
)}
>
{icon && <span className="shrink-0 text-slate-400 group-hover:text-slate-600">{icon}</span>}
<span className="flex-1 truncate">{children}</span>
{selected && (
<span className="text-[10px] text-sky-600 shrink-0"></span>
)}
</button>
);
}
export function PopoverMenuSeparator() {
return <div className="my-1 h-px bg-slate-200" />;
}
export function PopoverMenuKbd({ children }: { children: React.ReactNode }) {
return (
<span className="ml-auto font-mono text-[10px] text-slate-400 tabular-nums shrink-0">
{children}
</span>
);
}
/**
* SelectButton — convenience trigger styled to match the rest of the
* brae chrome. Pairs with PopoverMenu out of the box.
*/
export function SelectButton({
icon,
label,
placeholder,
className,
}: {
icon?: React.ReactNode;
label?: string;
placeholder?: string;
className?: string;
}) {
return (
<button
type="button"
className={cn(
"h-7 px-2 rounded-md border border-slate-200 hover:border-slate-300 bg-white text-slate-700 hover:text-slate-900 inline-flex items-center gap-1.5 text-[12px] font-medium transition-colors",
className,
)}
>
{icon && <span className="text-slate-400 shrink-0">{icon}</span>}
<span className="truncate max-w-[160px]">{label ?? placeholder ?? ""}</span>
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
className="text-slate-400 shrink-0"
>
<path d="M6 9l6 6 6-6" />
</svg>
</button>
);
}