feat(web): groups list + group detail pages with trigram search

This commit is contained in:
yiekheng 2026-05-09 23:32:00 +08:00
parent 7708dd671c
commit 6b1a9191ab
3 changed files with 306 additions and 0 deletions

View File

@ -0,0 +1,135 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import {
ArrowLeftIcon,
SearchIcon,
UsersIcon,
RefreshCwIcon,
Users2Icon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
} from "@/components/ui/card";
import { getSeededOperator } from "@/lib/operator";
import { listGroupsForAccount } from "@/lib/queries";
interface Props {
params: Promise<{ id: string }>;
searchParams: Promise<{ q?: string }>;
}
export default async function GroupsListPage({ params, searchParams }: Props) {
const { id } = await params;
const { q } = await searchParams;
const op = await getSeededOperator();
const data = await listGroupsForAccount(op.id, id, q);
if (!data) {
notFound();
}
const { account, groups } = data;
return (
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-3xl mx-auto space-y-6">
{/* Back link */}
<Button asChild variant="ghost" size="sm" className="-ml-2">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={`/accounts/${account.id}` as any}>
<ArrowLeftIcon />
{account.label}
</Link>
</Button>
{/* Header */}
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-semibold tracking-tight">
Groups in {account.label}
</h1>
<Badge variant="secondary" className="h-6 px-2.5 text-xs tabular-nums">
{groups.length}
</Badge>
</div>
{/* Refresh button — no-op placeholder, wired in Task 17 */}
<form action={async () => { "use server"; /* wired in Task 17 */ }}>
<Button type="submit" variant="outline" size="sm" className="shrink-0">
<RefreshCwIcon />
Refresh Groups
</Button>
</form>
</div>
{/* Search */}
<form method="GET" className="relative">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground pointer-events-none" />
<Input
name="q"
type="search"
placeholder="Search groups…"
defaultValue={q ?? ""}
className="pl-9"
/>
</form>
{/* Group list */}
{groups.length > 0 ? (
<div className="divide-y divide-border rounded-xl ring-1 ring-foreground/10 overflow-hidden bg-card">
{groups.map((group) => (
<Link
key={group.id}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/groups/${group.id}` as any}
className="flex items-center justify-between gap-4 px-4 py-3.5 hover:bg-muted/50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
>
<div className="flex items-center gap-3 min-w-0">
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted">
<UsersIcon className="size-4 text-muted-foreground" />
</div>
<span className="font-medium text-sm truncate">{group.name}</span>
</div>
<span className="text-xs text-muted-foreground shrink-0 tabular-nums">
{group.participantCount} member{group.participantCount !== 1 ? "s" : ""}
</span>
</Link>
))}
</div>
) : (
<Card>
<CardContent className="flex flex-col items-center gap-4 py-12 text-center">
<Users2Icon className="size-10 text-muted-foreground/40" />
<div className="space-y-1">
{q ? (
<>
<p className="text-sm font-medium">No groups match &ldquo;{q}&rdquo;</p>
<p className="text-xs text-muted-foreground">
Try a different search term or clear the search to see all groups.
</p>
</>
) : (
<>
<p className="text-sm font-medium">No groups synced yet</p>
<p className="text-xs text-muted-foreground">
Use &ldquo;Refresh Groups&rdquo; to pull the latest groups from this WhatsApp account.
</p>
</>
)}
</div>
{q && (
<Button asChild variant="outline" size="sm">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={`/accounts/${account.id}/groups` as any}>Clear search</Link>
</Button>
)}
</CardContent>
</Card>
)}
</div>
);
}

View File

@ -0,0 +1,130 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import {
ArrowLeftIcon,
UsersIcon,
SendIcon,
BellPlusIcon,
ClockIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { getSeededOperator } from "@/lib/operator";
import { getGroup } from "@/lib/queries";
interface Props {
params: Promise<{ id: string }>;
}
async function _sendTestStub(_formData: FormData) {
"use server";
// wired in Task 18
}
export default async function GroupDetailPage({ params }: Props) {
const { id } = await params;
const op = await getSeededOperator();
const data = await getGroup(op.id, id);
if (!data) {
notFound();
}
const { group, account } = data;
const lastSynced = group.lastSyncedAt
? new Date(group.lastSyncedAt).toLocaleDateString("en-MY", {
timeZone: "Asia/Kuala_Lumpur",
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
: "Never";
return (
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-2xl mx-auto space-y-6">
{/* Back link */}
<Button asChild variant="ghost" size="sm" className="-ml-2">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={`/accounts/${group.accountId}/groups` as any}>
<ArrowLeftIcon />
Back to Groups
</Link>
</Button>
{/* Hero */}
<div className="space-y-1">
<h1 className="text-2xl font-semibold tracking-tight">{group.name}</h1>
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
<UsersIcon className="size-3.5 shrink-0" />
Account: {account.label}
{" · "}
{group.participantCount} member{group.participantCount !== 1 ? "s" : ""}
</p>
</div>
{/* Send Test Message */}
<Card>
<CardHeader>
<CardTitle>Send Test Message</CardTitle>
<CardDescription>
Send a one-off message to this group to verify the connection.
</CardDescription>
</CardHeader>
<CardContent>
<form action={_sendTestStub} className="space-y-3">
<Textarea
name="text"
placeholder="Type your test message…"
rows={3}
className="resize-none"
/>
<Button type="submit" size="sm">
<SendIcon />
Send Message
</Button>
</form>
</CardContent>
</Card>
{/* Use in reminder */}
<Card>
<CardContent className="flex items-center justify-between gap-4 py-4">
<div className="flex items-center gap-3">
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
<BellPlusIcon className="size-4 text-muted-foreground" />
</div>
<div>
<p className="text-sm font-medium">Use in a Reminder</p>
<p className="text-xs text-muted-foreground">
Schedule recurring messages to this group
</p>
</div>
</div>
<Button asChild variant="outline" size="sm" className="shrink-0">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={`/reminders/new?groupId=${group.id}` as any}>
New Reminder
</Link>
</Button>
</CardContent>
</Card>
{/* Last synced */}
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<ClockIcon className="size-3.5 shrink-0" />
Last synced: {lastSynced}
</p>
</div>
);
}

View File

@ -43,3 +43,44 @@ export async function getAccount(operatorId: string, accountId: string) {
where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, operatorId)), where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, operatorId)),
}); });
} }
export async function listGroupsForAccount(operatorId: string, accountId: string, q?: string) {
const account = await getAccount(operatorId, accountId);
if (!account) return null;
const trimmed = (q ?? "").trim();
const rows = trimmed
? await db.execute(sql`
SELECT id, account_id, wa_group_jid, name, participant_count, is_archived, last_synced_at
FROM whatsapp_groups
WHERE account_id = ${accountId} AND name % ${trimmed}
ORDER BY similarity(name, ${trimmed}) DESC
LIMIT 50
`)
: await db.execute(sql`
SELECT id, account_id, wa_group_jid, name, participant_count, is_archived, last_synced_at
FROM whatsapp_groups
WHERE account_id = ${accountId}
ORDER BY name ASC
LIMIT 200
`);
const groups = (rows.rows as Array<Record<string, unknown>>).map((r) => ({
id: r.id as string,
accountId: r.account_id as string,
waGroupJid: r.wa_group_jid as string,
name: r.name as string,
participantCount: Number(r.participant_count),
isArchived: r.is_archived as boolean,
lastSyncedAt: r.last_synced_at as Date,
}));
return { account, groups };
}
export async function getGroup(operatorId: string, groupId: string) {
const group = await db.query.whatsappGroups.findFirst({
where: (g, { eq }) => eq(g.id, groupId),
});
if (!group) return null;
const account = await getAccount(operatorId, group.accountId);
if (!account) return null;
return { group, account };
}