mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-07 16:02:16 +00:00
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:
@@ -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 (A–Z)</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user