@@ -785,29 +54,13 @@ export function HomePage(_props: SectionPageProps) {
- {renderTile(tile("discovery"))}
-
diff --git a/apps/admin/src/features/home/home-preview-rows.ts b/apps/admin/src/features/home/home-preview-rows.ts
new file mode 100644
index 0000000..9f8efa3
--- /dev/null
+++ b/apps/admin/src/features/home/home-preview-rows.ts
@@ -0,0 +1,141 @@
+import {
+ MODERATION_KIND_LABELS,
+ relativeAgo,
+ type AdminEventListItemDTO,
+ type AdminReportListItemDTO,
+ type AdminUserListItemDTO,
+ type DiscoveryTaskDTO,
+ type InboundEmailListItemDTO,
+ type MailDirection,
+ type MailThreadListItemDTO,
+ type ModerationListItemDTO,
+ type ReportCategory,
+} from "@civfix/shared"
+
+import { Icons, type IconComponent } from "@/components/icons"
+import type { Hue } from "@/features/analytics/analytics-charts"
+import { EMPTY_VALUE } from "@/lib/empty-value"
+import { reportStatusView } from "@/lib/report-status"
+
+export interface PeekItem {
+ kind: "pin" | "icon" | "dir" | "avatar"
+ cat?: ReportCategory
+ icon?: IconComponent
+ hue?: Hue
+ dir?: MailDirection
+ name?: string
+ title: string
+ meta: string
+ age: string
+ focusId: string
+}
+
+const POPULATION_FORMAT = new Intl.NumberFormat("en", {
+ notation: "compact",
+ maximumFractionDigits: 1,
+})
+
+function compactPopulation(population: number): string {
+ return population > 0 ? POPULATION_FORMAT.format(population) : ""
+}
+
+export function discoveryRow(task: DiscoveryTaskDTO): PeekItem {
+ const population = compactPopulation(task.pop)
+ return {
+ kind: "pin",
+ cat: task.category,
+ title: task.place,
+ meta: population ? `${task.reports} reports · pop ${population}` : `${task.reports} reports`,
+ age: task.age,
+ focusId: task.geoid,
+ }
+}
+
+export function reportRow(report: AdminReportListItemDTO): PeekItem {
+ return {
+ kind: "pin",
+ cat: report.category,
+ title: report.title,
+ meta: `${report.place} · ${reportStatusView(report.status).label}`,
+ age: report.submitted.rel,
+ focusId: report.id,
+ }
+}
+
+export function eventRow(event: AdminEventListItemDTO): PeekItem {
+ return {
+ kind: "icon",
+ icon: Icons.Calendar,
+ hue: "sun",
+ title: event.title,
+ meta: `${event.place} · ${event.attendees} attending`,
+ age: event.date.rel,
+ focusId: event.id,
+ }
+}
+
+function mailRow(thread: MailThreadListItemDTO): PeekItem {
+ return {
+ kind: "dir",
+ dir: thread.dir,
+ title: thread.org,
+ meta: thread.subject,
+ age: relativeAgo(thread.ts),
+ focusId: thread.id,
+ }
+}
+
+function inboxRow(email: InboundEmailListItemDTO): PeekItem {
+ return {
+ kind: "icon",
+ icon: Icons.Inbox,
+ hue: "sky",
+ title: email.from || email.recipient,
+ meta: email.subject || "(no subject)",
+ age: relativeAgo(email.ts),
+ focusId: `inbox:${email.id}`,
+ }
+}
+
+export function mailPreviewRows(
+ outreach: readonly MailThreadListItemDTO[],
+ inbound: readonly InboundEmailListItemDTO[],
+ limit: number,
+): PeekItem[] {
+ // Both feeds serialize ts with toISOString, so comparing the strings orders them by time.
+ const newestFirst = (a: { ts: string }, b: { ts: string }) => (a.ts < b.ts ? 1 : a.ts > b.ts ? -1 : 0)
+ return [
+ ...outreach.map((thread) => ({ ts: thread.ts, row: mailRow(thread) })),
+ ...inbound.map((email) => ({ ts: email.ts, row: inboxRow(email) })),
+ ]
+ .sort(newestFirst)
+ .slice(0, limit)
+ .map((entry) => entry.row)
+}
+
+export function userRow(user: AdminUserListItemDTO): PeekItem {
+ return {
+ kind: "avatar",
+ name: user.name,
+ title: user.name,
+ meta: user.flagReason ?? (user.city || EMPTY_VALUE),
+ age: user.lastActive,
+ focusId: user.id,
+ }
+}
+
+export function moderationRow(item: ModerationListItemDTO): PeekItem {
+ const title =
+ item.kind === "user_report" && item.subjectType
+ ? `Reported ${item.subjectType}`
+ : MODERATION_KIND_LABELS[item.kind]
+ return {
+ kind: "icon",
+ icon: Icons.Flag,
+ hue: "lilac",
+ title,
+ meta: `${item.reason} · ${item.reporter}`,
+ age: item.age,
+ focusId: item.id,
+ }
+}
diff --git a/apps/admin/src/features/home/home-preview-state.ts b/apps/admin/src/features/home/home-preview-state.ts
new file mode 100644
index 0000000..060aa78
--- /dev/null
+++ b/apps/admin/src/features/home/home-preview-state.ts
@@ -0,0 +1,90 @@
+import type { UseQueryResult } from "@tanstack/react-query"
+
+export interface PreviewState {
+ isLoading: boolean
+ isError: boolean
+ error: unknown
+ onRetry: () => void
+ hasMore: boolean
+ /** One source of a merged tile failed while the other loaded; its rows still show. */
+ partialError?: { title: string; error: unknown; onRetry: () => void }
+}
+
+interface ListPage
{
+ items: T[]
+ nextCursor: string | null
+}
+
+type ListQuery = UseQueryResult>
+
+export function previewState(query: ListQuery, shown: number): PreviewState {
+ return {
+ isLoading: query.isLoading,
+ isError: query.isError,
+ error: query.error,
+ onRetry: () => {
+ void query.refetch()
+ },
+ hasMore: query.data?.nextCursor != null || (query.data?.items.length ?? 0) > shown,
+ }
+}
+
+function mailPartialError(
+ outreachQuery: ListQuery,
+ inboxQuery: ListQuery,
+): PreviewState["partialError"] {
+ if (outreachQuery.isError && !inboxQuery.isError) {
+ return {
+ title: "Could not load outreach mail",
+ error: outreachQuery.error,
+ onRetry: () => void outreachQuery.refetch(),
+ }
+ }
+ if (inboxQuery.isError && !outreachQuery.isError) {
+ return {
+ title: "Could not load the inbox",
+ error: inboxQuery.error,
+ onRetry: () => void inboxQuery.refetch(),
+ }
+ }
+ return undefined
+}
+
+export function mailPreviewState(
+ outreachQuery: ListQuery,
+ inboxQuery: ListQuery,
+ shown: number,
+): PreviewState {
+ const loaded = (outreachQuery.data?.items.length ?? 0) + (inboxQuery.data?.items.length ?? 0)
+ return {
+ isLoading: outreachQuery.isLoading || inboxQuery.isLoading,
+ isError: outreachQuery.isError && inboxQuery.isError,
+ error: outreachQuery.error ?? inboxQuery.error,
+ onRetry: () => {
+ void outreachQuery.refetch()
+ void inboxQuery.refetch()
+ },
+ hasMore:
+ outreachQuery.data?.nextCursor != null || inboxQuery.data?.nextCursor != null || loaded > shown,
+ partialError: mailPartialError(outreachQuery, inboxQuery),
+ }
+}
+
+export interface TileNoun {
+ one: string
+ many: string
+}
+
+export function moreLabel(
+ state: PreviewState,
+ shown: number,
+ noun: TileNoun,
+ total?: number,
+): string | null {
+ if (state.isLoading || state.isError || shown === 0) return null
+ if (total !== undefined) {
+ const remaining = Math.max(0, total - shown)
+ return remaining > 0 ? `… and ${remaining} more ${remaining === 1 ? noun.one : noun.many}` : null
+ }
+ return state.hasMore ? `… and more ${noun.many}` : null
+}
diff --git a/apps/admin/src/features/home/home-summaries.ts b/apps/admin/src/features/home/home-summaries.ts
new file mode 100644
index 0000000..f5edafb
--- /dev/null
+++ b/apps/admin/src/features/home/home-summaries.ts
@@ -0,0 +1,173 @@
+import type { ReactNode } from "react"
+import type { HomeSummaryResponse } from "@civfix/shared"
+
+import type { Hue } from "@/features/analytics/analytics-charts"
+import {
+ getMailPreviewPresentation,
+ getModerationPreviewPresentation,
+} from "@/features/home/home-preview-presentation"
+import type { PageId } from "@/store/ui-store"
+
+export interface SectionStat {
+ k: string
+ v: ReactNode
+ tone?: "warn" | "alert" | null
+}
+
+export interface SectionMetric {
+ k: string
+ v: ReactNode
+}
+
+export interface SectionSummary {
+ page: PageId
+ label: string
+ hue: Hue
+ /** Null when no trustworthy server-side total exists for this section; the tile then leads with `unit`. */
+ lead: number | null
+ unit: string
+ blurb?: string
+ stats: SectionStat[]
+ cta: string
+ spark?: number[]
+ metrics?: SectionMetric[]
+}
+
+export interface HomeSections {
+ discovery: SectionSummary
+ reports: SectionSummary
+ events: SectionSummary
+ mail: SectionSummary
+ users: SectionSummary
+ analytics: SectionSummary
+}
+
+const NO_COUNT = { lead: null, unit: "" } as const
+
+function counted(lead: number | undefined, unit: string): Pick {
+ return lead === undefined ? NO_COUNT : { lead, unit }
+}
+
+export function buildSections(data: HomeSummaryResponse | undefined): HomeSections {
+ return {
+ discovery: {
+ page: "discovery",
+ label: "Jurisdictions",
+ hue: "slate",
+ ...counted(data?.discovery.queue, "jurisdictions in queue"),
+ blurb:
+ "Jurisdictions with reports waiting on routing setup. Work the queue so neighbors' reports reach the right city department.",
+ stats: [
+ { k: "Reports waiting", v: data?.discovery.reportsWaiting },
+ { k: "Over SLA", v: data?.discovery.overSla, tone: (data?.discovery.overSla ?? 0) > 0 ? "warn" : null },
+ ],
+ cta: "Open",
+ },
+ reports: {
+ page: "reports",
+ label: "Reports",
+ hue: "lilac",
+ ...counted(data?.reports.flagged, "reports flagged"),
+ blurb:
+ "Every report neighbors submit, routed to the right city department. Track status and close the loop.",
+ stats: [
+ { k: "In progress", v: data?.reports.inProgress },
+ { k: "Completed", v: data?.reports.completed },
+ ],
+ cta: "Open reports",
+ },
+ events: {
+ page: "events",
+ label: "Events",
+ hue: "sun",
+ ...counted(data?.events.upcoming, "upcoming events"),
+ blurb:
+ "Community cleanups neighbors organize. Track turnout, keep them legit, and message attendees.",
+ stats: [
+ { k: "Live now", v: data?.events.live },
+ { k: "Attending", v: data?.events.attending },
+ ],
+ cta: "Open events",
+ },
+ mail: {
+ page: "mail",
+ label: "Mail",
+ hue: "sky",
+ ...counted(data?.mail.unread, "unread messages"),
+ blurb:
+ "Two-way mail with municipal contacts: outbound routing and the replies that come back.",
+ stats: [
+ { k: "Needs action", v: data?.mail.needsAction, tone: (data?.mail.needsAction ?? 0) > 0 ? "warn" : null },
+ { k: "Unread", v: data?.mail.unread },
+ ],
+ cta: "Open inbox",
+ },
+ users: {
+ page: "users",
+ label: "Users",
+ hue: "sun",
+ ...counted(data?.users.flagged, "accounts flagged"),
+ blurb: "Most neighbors never appear here. The queue surfaces the few who need a trust review.",
+ stats: [
+ { k: "High risk", v: data?.users.highRisk, tone: (data?.users.highRisk ?? 0) > 0 ? "alert" : null },
+ { k: "Suspended", v: data?.users.suspended },
+ ],
+ cta: "Review accounts",
+ },
+ analytics: {
+ page: "analytics",
+ label: "Analytics",
+ hue: "moss",
+ ...counted(data?.analytics.pinsThisMonth, "pins this month"),
+ blurb: "The numbers are the proof civfix works: dropped, routed, resolved, cleaned up.",
+ stats: [{ k: "Cleanups", v: data?.analytics.cleanups }],
+ spark: data?.analytics.pinsByWeek,
+ metrics: data && [
+ { k: "Resolved", v: `${data.analytics.resolvedPct}%` },
+ { k: "Coverage", v: `${data.analytics.coveragePct}%` },
+ { k: "Events", v: data.analytics.eventsThisMonth },
+ { k: "New users", v: data.analytics.newUsers },
+ ],
+ cta: "See analytics",
+ },
+ }
+}
+
+export function mailSection(
+ base: SectionSummary,
+ data: HomeSummaryResponse | undefined,
+): SectionSummary {
+ const needsAction = data?.mail.needsAction ?? 0
+ const inboxUnread = data?.inboxUnread
+ const presentation = base.lead === null ? NO_COUNT : getMailPreviewPresentation(base.lead)
+ return {
+ ...base,
+ lead: presentation.lead,
+ unit: presentation.unit,
+ blurb:
+ "Two-way outreach with municipal contacts plus catch-all inbound to *@civfix.org: replies, support requests, and cold mail in one place.",
+ cta: "Open mail",
+ stats: [
+ { k: "Needs action", v: needsAction, tone: needsAction > 0 ? "warn" : null },
+ ...(inboxUnread === undefined
+ ? []
+ : [{ k: "Inbox unread", v: inboxUnread, tone: null } satisfies SectionStat]),
+ ],
+ }
+}
+
+export function moderationPresentation(data: HomeSummaryResponse | undefined) {
+ return data ? getModerationPreviewPresentation(data.moderationQueue) : NO_COUNT
+}
+
+export function moderationSection(lead: number | null, unit: string): SectionSummary {
+ return {
+ page: "moderation",
+ label: "Moderation",
+ hue: "lilac",
+ lead,
+ unit,
+ stats: [],
+ cta: "Open moderation",
+ }
+}
diff --git a/apps/admin/src/features/home/host-platform-launcher.tsx b/apps/admin/src/features/home/host-platform-launcher.tsx
new file mode 100644
index 0000000..8e4ae63
--- /dev/null
+++ b/apps/admin/src/features/home/host-platform-launcher.tsx
@@ -0,0 +1,54 @@
+"use client"
+
+import { Icons, type IconComponent } from "@/components/icons"
+import type { Hue } from "@/features/analytics/analytics-charts"
+import { PAGE_LABEL, useNav, type PageId } from "@/store/ui-store"
+
+const HOST_PLATFORM_SECTIONS: {
+ page: Extract
+ hue: Hue
+ icon: IconComponent
+ sub: string
+}[] = [
+ {
+ page: "orgs",
+ hue: "sky",
+ icon: Icons.Building,
+ sub: "Create and manage orgs, verify, members, events",
+ },
+ { page: "hosts", hue: "bloom", icon: Icons.Send, sub: "Broadcast counters and the kill switch" },
+ { page: "pages", hue: "lilac", icon: Icons.Globe, sub: "Public signup pages: flag or unpublish" },
+]
+
+export function HostPlatformLauncher() {
+ const nav = useNav()
+ return (
+
+
+
Host platform
+
+
+ {HOST_PLATFORM_SECTIONS.map((section) => {
+ const SectionIcon = section.icon
+ return (
+ nav(section.page)}
+ >
+
+
+
+
+ {PAGE_LABEL[section.page]}
+ {section.sub}
+
+
+
+ )
+ })}
+
+
+ )
+}
diff --git a/apps/admin/src/features/home/preview-list.tsx b/apps/admin/src/features/home/preview-list.tsx
new file mode 100644
index 0000000..9133a60
--- /dev/null
+++ b/apps/admin/src/features/home/preview-list.tsx
@@ -0,0 +1,119 @@
+"use client"
+
+import { Icons } from "@/components/icons"
+import { LoadingState, ErrorState } from "@/components/shared/data-states"
+import type { PeekItem } from "@/features/home/home-preview-rows"
+import type { PreviewState } from "@/features/home/home-preview-state"
+import { categoryPinSrc } from "@/lib/category"
+import { INITIALS_MAX_LETTERS } from "@/lib/display"
+import { useNav, type PageId } from "@/store/ui-store"
+
+function initials(name: string): string {
+ return name
+ .split(" ")
+ .map((word) => word[0] ?? "")
+ .slice(0, INITIALS_MAX_LETTERS)
+ .join("")
+ .toUpperCase()
+}
+
+function PeekGlyph({ item }: { item: PeekItem }) {
+ if (item.kind === "pin") {
+ const src = item.cat ? categoryPinSrc(item.cat) : null
+ return (
+
+ {src ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : (
+
+ )}
+
+ )
+ }
+ if (item.kind === "icon") {
+ const Glyph = item.icon ?? Icons.Shield
+ return (
+
+
+
+ )
+ }
+ if (item.kind === "dir") {
+ return (
+
+ {item.dir === "in" ? : }
+
+ )
+ }
+ return {initials(item.name ?? "")}
+}
+
+function PreviewRow({ item, page }: { item: PeekItem; page: PageId }) {
+ const nav = useNav()
+ const open = () => nav(page, item.focusId)
+ return (
+
+
+
+ {item.title}
+ {item.meta}
+
+ {item.age}
+
+
+
+
+ )
+}
+
+export function PreviewList({
+ page,
+ state,
+ rows,
+}: {
+ page: PageId
+ state: PreviewState
+ rows: PeekItem[]
+}) {
+ if (state.isLoading) {
+ return (
+
+
+
+ )
+ }
+ if (state.isError) {
+ return (
+
+
+
+ )
+ }
+ const partial = state.partialError && (
+
+ )
+ if (rows.length === 0) {
+ return (
+
+ {partial ??
Nothing here right now.
}
+
+ )
+ }
+ return (
+
+ {rows.map((item) => (
+
+ ))}
+ {partial}
+
+ )
+}
diff --git a/apps/admin/src/features/home/section-tiles.tsx b/apps/admin/src/features/home/section-tiles.tsx
new file mode 100644
index 0000000..15024e4
--- /dev/null
+++ b/apps/admin/src/features/home/section-tiles.tsx
@@ -0,0 +1,154 @@
+"use client"
+
+import type { ReactNode } from "react"
+
+import { Icons, type IconComponent } from "@/components/icons"
+import { Spark } from "@/features/analytics/analytics-charts"
+import type { SectionSummary } from "@/features/home/home-summaries"
+import { useNav, type PageId } from "@/store/ui-store"
+
+const HUB_ICON: Partial> = {
+ discovery: Icons.Pin,
+ reports: Icons.FileText,
+ events: Icons.Calendar,
+ mail: Icons.Mail,
+ users: Icons.Users,
+ moderation: Icons.Shield,
+ analytics: Icons.BarChart,
+}
+
+function tileClassName(summary: SectionSummary, feature: boolean | undefined): string {
+ return `stile hue-${summary.hue} ${feature ? "feature" : ""}`
+}
+
+function TileHead({
+ summary,
+ onOpen,
+ children,
+}: {
+ summary: SectionSummary
+ onOpen: () => void
+ children?: ReactNode
+}) {
+ const SectionIcon = HUB_ICON[summary.page] ?? Icons.Layers
+ return (
+
+
+
+
+ {summary.label}
+ {children}
+
+ )
+}
+
+function TileFoot({
+ onOpen,
+ cta,
+ children,
+}: {
+ onOpen: () => void
+ cta: string
+ children?: ReactNode
+}) {
+ return (
+
+ {children}
+
+
+ {cta}
+
+
+ )
+}
+
+function MetricTrend({ summary }: { summary: SectionSummary }) {
+ if (!summary.spark) return null
+ if (!summary.spark.some((v) => v > 0)) return No data yet
+ return (
+
+ )
+}
+
+export function MetricSectionTile({ summary }: { summary: SectionSummary }) {
+ const nav = useNav()
+ const open = () => nav(summary.page)
+ return (
+
+
+
+
+
+ {summary.lead}
+ {summary.unit}
+
+
+
+ {summary.metrics && (
+
+ {summary.metrics.map((metric) => (
+
+
+ {metric.v}
+
+
{metric.k}
+
+ ))}
+
+ )}
+
+
+
+ {summary.stats.map((stat) => (
+
+ {stat.v} {stat.k}
+
+ ))}
+
+
+
+ )
+}
+
+export function PreviewSectionTile({
+ summary,
+ feature,
+ moreLabel,
+ children,
+}: {
+ summary: SectionSummary
+ feature?: boolean
+ moreLabel: string | null
+ children: ReactNode
+}) {
+ const nav = useNav()
+ const open = () => nav(summary.page)
+ return (
+
+
+ {summary.unit && (
+
+ {summary.lead === null ? (
+ summary.unit
+ ) : (
+ <>
+ {summary.lead} {summary.unit}
+ >
+ )}
+
+ )}
+
+
+ {feature && summary.blurb &&
{summary.blurb}
}
+ {children}
+
+
+ {moreLabel && {moreLabel} }
+
+
+ )
+}
diff --git a/apps/admin/src/features/home/use-home-tiles.ts b/apps/admin/src/features/home/use-home-tiles.ts
new file mode 100644
index 0000000..3534994
--- /dev/null
+++ b/apps/admin/src/features/home/use-home-tiles.ts
@@ -0,0 +1,149 @@
+"use client"
+
+import * as React from "react"
+
+import { useHomeSummary } from "@/hooks/use-admin-home"
+import { useDiscoveryList } from "@/features/discovery/use-discovery"
+import { useReportList } from "@/features/reports/use-reports"
+import { useEventList } from "@/features/events/use-events"
+import { useMailList } from "@/features/mail/use-mail"
+import { useInboxList } from "@/features/inbox/use-inbox"
+import { useUserList } from "@/features/users/use-users"
+import { useModerationList } from "@/features/moderation/use-moderation"
+import {
+ discoveryRow,
+ eventRow,
+ mailPreviewRows,
+ moderationRow,
+ reportRow,
+ userRow,
+ type PeekItem,
+} from "@/features/home/home-preview-rows"
+import {
+ mailPreviewState,
+ moreLabel,
+ previewState,
+ type PreviewState,
+ type TileNoun,
+} from "@/features/home/home-preview-state"
+import {
+ buildSections,
+ mailSection,
+ moderationPresentation,
+ moderationSection,
+ type SectionSummary,
+} from "@/features/home/home-summaries"
+
+const PREVIEW_ROWS = 2
+// One row past the preview tells a tile whether more exist without a count query.
+const PREVIEW_FETCH_LIMIT = PREVIEW_ROWS + 1
+
+type PreviewPage = "discovery" | "mail" | "moderation" | "users" | "reports" | "events"
+
+const TILE_NOUN: Record = {
+ discovery: { one: "city", many: "cities" },
+ mail: { one: "message", many: "messages" },
+ moderation: { one: "item", many: "items" },
+ users: { one: "account", many: "accounts" },
+ reports: { one: "report", many: "reports" },
+ events: { one: "event", many: "events" },
+}
+
+export interface PreviewTileModel {
+ cell: string
+ feature?: boolean
+ summary: SectionSummary
+ state: PreviewState
+ rows: PeekItem[]
+ moreLabel: string | null
+}
+
+function previewTile(
+ page: PreviewPage,
+ summary: SectionSummary,
+ state: PreviewState,
+ rows: PeekItem[],
+ opts?: { feature?: boolean; total?: number },
+): PreviewTileModel {
+ return {
+ cell: `bt-${page}`,
+ feature: opts?.feature,
+ summary,
+ state,
+ rows,
+ moreLabel: moreLabel(state, rows.length, TILE_NOUN[page], opts?.total),
+ }
+}
+
+function previewRows(data: { items: T[] } | undefined, toRow: (item: T) => PeekItem): PeekItem[] {
+ return (data?.items ?? []).slice(0, PREVIEW_ROWS).map(toRow)
+}
+
+// Every list query runs unconditionally and independently of the summary, so one failing source
+// never takes down another tile.
+export function useHomeTiles() {
+ const summaryQuery = useHomeSummary()
+
+ const discoveryQuery = useDiscoveryList({ limit: PREVIEW_FETCH_LIMIT })
+ const reportsQuery = useReportList({ limit: PREVIEW_FETCH_LIMIT })
+ const eventsQuery = useEventList({ limit: PREVIEW_FETCH_LIMIT })
+ const mailQuery = useMailList({ limit: PREVIEW_FETCH_LIMIT })
+ const inboxQuery = useInboxList({ status: "all", limit: PREVIEW_FETCH_LIMIT })
+ const usersQuery = useUserList({ limit: PREVIEW_FETCH_LIMIT })
+ const moderationQuery = useModerationList({ limit: PREVIEW_FETCH_LIMIT })
+
+ const summary = summaryQuery.data
+ const sections = React.useMemo(() => buildSections(summary), [summary])
+ const mailSummary = React.useMemo(() => mailSection(sections.mail, summary), [sections, summary])
+ const moderation = moderationPresentation(summary)
+ const moderationSummary = React.useMemo(
+ () => moderationSection(moderation.lead, moderation.unit),
+ [moderation.lead, moderation.unit],
+ )
+
+ const tiles: Record = {
+ discovery: previewTile(
+ "discovery",
+ sections.discovery,
+ previewState(discoveryQuery, PREVIEW_ROWS),
+ previewRows(discoveryQuery.data, discoveryRow),
+ { feature: true, total: summary?.discovery.queue },
+ ),
+ mail: previewTile(
+ "mail",
+ mailSummary,
+ mailPreviewState(mailQuery, inboxQuery, PREVIEW_ROWS),
+ mailPreviewRows(mailQuery.data?.items ?? [], inboxQuery.data?.items ?? [], PREVIEW_ROWS),
+ ),
+ moderation: previewTile(
+ "moderation",
+ moderationSummary,
+ previewState(moderationQuery, PREVIEW_ROWS),
+ previewRows(moderationQuery.data, moderationRow),
+ ),
+ users: previewTile(
+ "users",
+ sections.users,
+ previewState(usersQuery, PREVIEW_ROWS),
+ previewRows(usersQuery.data, userRow),
+ ),
+ reports: previewTile(
+ "reports",
+ sections.reports,
+ previewState(reportsQuery, PREVIEW_ROWS),
+ previewRows(reportsQuery.data, reportRow),
+ ),
+ events: previewTile(
+ "events",
+ sections.events,
+ previewState(eventsQuery, PREVIEW_ROWS),
+ previewRows(eventsQuery.data, eventRow),
+ ),
+ }
+
+ return {
+ summaryQuery,
+ analytics: summary ? sections.analytics : undefined,
+ tiles,
+ }
+}
diff --git a/apps/admin/src/features/hosts/broadcast-log.tsx b/apps/admin/src/features/hosts/broadcast-log.tsx
index 6c53093..bd1f87c 100644
--- a/apps/admin/src/features/hosts/broadcast-log.tsx
+++ b/apps/admin/src/features/hosts/broadcast-log.tsx
@@ -12,7 +12,12 @@ import { formatDateTime } from "@/lib/dates"
import { EMPTY_VALUE } from "@/lib/empty-value"
import { useNav } from "@/store/ui-store"
-export const BROADCAST_STATUS_VIEW: Record = {
+interface BroadcastStatusView {
+ label: string
+ cls: string
+}
+
+const BROADCAST_STATUS_VIEW: Record = {
draft: { label: "Draft", cls: "priority-low" },
scheduled: { label: "Scheduled", cls: "status-new" },
sending: { label: "Sending", cls: "status-progress" },
@@ -21,11 +26,11 @@ export const BROADCAST_STATUS_VIEW: Record = {
+const BROADCAST_KIND_LABEL: Record = {
host_broadcast: "Host broadcast",
confirmation: "Confirmation",
waitlist_promoted: "Waitlist promoted",
@@ -36,8 +41,61 @@ export const BROADCAST_KIND_LABEL: Record = {
announcement: "Announcement",
}
-export function BroadcastLog({ items }: { items: AdminBroadcastListItemDTO[] }) {
+function BroadcastRow({ item }: { item: AdminBroadcastListItemDTO }) {
const nav = useNav()
+ const view = broadcastStatusView(item.status)
+ return (
+
+
+
{BROADCAST_KIND_LABEL[item.kind]}
+
{view.label}
+
+
{formatDateTime(item.createdAt)}
+
+
+ nav("events", item.cleanupId)}
+ >
+ {item.eventTitle ?? "Event"}
+
+ ·
+ {item.channels.length > 0 ? item.channels.join(", ") : "no channel"}
+ ·
+
+ {item.recipientCount.toLocaleString()} recipients
+
+ ·
+
+ {item.sentCount.toLocaleString()} sent
+
+ {item.failedCount > 0 && (
+ <>
+ ·
+
+ {item.failedCount.toLocaleString()} failed
+
+ >
+ )}
+ {item.suppressedCount > 0 && (
+ <>
+ ·
+
+ {item.suppressedCount.toLocaleString()} suppressed
+
+ >
+ )}
+
+
+ subject hash {item.subjectHash ?? EMPTY_VALUE} · finished {formatDateTime(item.finishedAt)}
+
+
+ )
+}
+
+export function BroadcastLog({ items }: { items: AdminBroadcastListItemDTO[] }) {
if (items.length === 0) {
return (
- {items.map((item) => {
- const view = broadcastStatusView(item.status)
- return (
-
-
-
{BROADCAST_KIND_LABEL[item.kind]}
-
{view.label}
-
-
{formatDateTime(item.createdAt)}
-
-
- nav("events", item.cleanupId)}
- >
- {item.eventTitle ?? "Event"}
-
- ·
- {item.channels.length > 0 ? item.channels.join(", ") : "no channel"}
- ·
-
- {item.recipientCount.toLocaleString()} recipients
-
- ·
-
- {item.sentCount.toLocaleString()} sent
-
- {item.failedCount > 0 && (
- <>
- ·
-
- {item.failedCount.toLocaleString()} failed
-
- >
- )}
- {item.suppressedCount > 0 && (
- <>
- ·
-
- {item.suppressedCount.toLocaleString()} suppressed
-
- >
- )}
-
-
- subject hash {item.subjectHash ?? EMPTY_VALUE} · finished {formatDateTime(item.finishedAt)}
-
-
- )
- })}
+ {items.map((item) => (
+
+ ))}
This log is content-free by design: civfix records counts and a
subject hash, never a subject line, a body or a recipient address.
diff --git a/apps/admin/src/features/hosts/host-detail.tsx b/apps/admin/src/features/hosts/host-detail.tsx
new file mode 100644
index 0000000..260d4d4
--- /dev/null
+++ b/apps/admin/src/features/hosts/host-detail.tsx
@@ -0,0 +1,280 @@
+"use client"
+
+import * as React from "react"
+import type { AdminBroadcastListItemDTO, AdminHostListItemDTO } from "@civfix/shared"
+
+import { Icons } from "@/components/icons"
+import { EmptyState } from "@/components/shared/page-primitives"
+import { LoadingState, ErrorState } from "@/components/shared/data-states"
+import { promptDialog } from "@/components/shared/dialog"
+import { formatDateTime } from "@/lib/dates"
+import { BroadcastLog } from "@/features/hosts/broadcast-log"
+import {
+ eventsFromBroadcasts,
+ hostBroadcastParams,
+ type HostActivityWindow,
+ type HostEventRef,
+} from "@/features/hosts/host-window"
+import { useAdminBroadcastsInfinite, useSetHostMessagingSuspended } from "@/features/hosts/use-hosts"
+import { useNav } from "@/store/ui-store"
+
+type BroadcastLogQuery = ReturnType
+
+function HostDetailHead({ row }: { row: AdminHostListItemDTO }) {
+ const suspended = row.messagingSuspended
+ return (
+
+
+
+
+
+
+
+
{row.host.handle}
+
{row.host.name}
+
+
+ {suspended ? (
+ <>
+ Messaging suspended
+ >
+ ) : (
+ <>
+ Messaging active
+ >
+ )}
+
+
+ )
+}
+
+function HostKpis({ row }: { row: AdminHostListItemDTO }) {
+ return (
+
+
+
Broadcasts
+
{row.broadcastCount.toLocaleString()}
+
last {row.windowDays} days
+
+
+
Recipients
+
{row.recipientCount.toLocaleString()}
+
resolved audience
+
+
+
Sent
+
{row.sentCount.toLocaleString()}
+
delivered to a channel
+
+
0 ? "tone-alert" : ""}`}>
+
Failed
+
{row.failedCount.toLocaleString()}
+
{row.suppressedCount.toLocaleString()} suppressed
+
+
+ )
+}
+
+function BroadcastLogSection({
+ logQuery,
+ broadcasts,
+}: {
+ logQuery: BroadcastLogQuery
+ broadcasts: AdminBroadcastListItemDTO[]
+}) {
+ return (
+
+
+ Broadcast log
+
+ {broadcasts.length}
+
+
+
+ {logQuery.isLoading ? (
+
+ ) : logQuery.isError ? (
+ logQuery.refetch()}
+ title="Could not load this host's broadcasts"
+ />
+ ) : (
+ <>
+
+ {logQuery.hasNextPage && (
+ void logQuery.fetchNextPage()}
+ >
+ {logQuery.isFetchingNextPage ? "Loading…" : "Load more broadcasts"}
+
+ )}
+ >
+ )}
+
+
+ )
+}
+
+function EventsReachedList({
+ logQuery,
+ events,
+}: {
+ logQuery: BroadcastLogQuery
+ events: HostEventRef[]
+}) {
+ const nav = useNav()
+ if (logQuery.isLoading) return
+ if (logQuery.isError) {
+ return (
+ }
+ />
+ )
+ }
+ if (events.length === 0) {
+ return (
+ }
+ />
+ )
+ }
+ return (
+
+ {events.map((event) => (
+ nav("events", event.cleanupId)}
+ >
+ {event.title}
+
+ ))}
+
+ )
+}
+
+function EventsReachedSection({
+ row,
+ logQuery,
+ events,
+}: {
+ row: AdminHostListItemDTO
+ logQuery: BroadcastLogQuery
+ events: HostEventRef[]
+}) {
+ return (
+
+
+ Events reached
+
+ {row.eventsMessaged.toLocaleString()}
+
+
+
+
+
+
+ )
+}
+
+function HostMessagingActions({ row }: { row: AdminHostListItemDTO }) {
+ const suspend = useSetHostMessagingSuspended()
+ const suspended = row.messagingSuspended
+
+ const onToggleSuspended = async () => {
+ const next = !suspended
+ const reason = await promptDialog({
+ title: next
+ ? `Suspend messaging for ${row.host.name}?`
+ : `Restore messaging for ${row.host.name}?`,
+ body: next
+ ? "Every broadcast this host composes, schedules or sends is refused until messaging is restored. In-flight sends stop mid-chunk. The reason is written to the audit log."
+ : "The host can compose and send broadcasts again, subject to the normal caps. The reason is written to the audit log.",
+ label: "Reason (required)",
+ placeholder: next
+ ? "Repeated off-topic broadcasts to event attendees…"
+ : "Reviewed with the host; the pattern has stopped…",
+ confirmLabel: next ? "Suspend messaging" : "Restore messaging",
+ required: true,
+ danger: next,
+ })
+ if (reason === null || reason.trim() === "") return
+ suspend.mutate({
+ request: { id: row.host.id, suspended: next, reason: reason.trim() },
+ hostName: row.host.name,
+ })
+ }
+
+ return (
+
+
Messaging
+
+
void onToggleSuspended()}
+ >
+ {suspended ? (
+ <>
+ Restore messaging
+ >
+ ) : (
+ <>
+ Suspend messaging
+ >
+ )}
+
+
+ )
+}
+
+export function HostDetail({
+ row,
+ activityWindow,
+}: {
+ row: AdminHostListItemDTO
+ activityWindow: HostActivityWindow
+}) {
+ const logParams = React.useMemo(
+ () => hostBroadcastParams(row.host.id, activityWindow),
+ [row.host.id, activityWindow],
+ )
+ const logQuery = useAdminBroadcastsInfinite(logParams)
+ const broadcasts = React.useMemo(
+ () => logQuery.data?.pages.flatMap((page) => page.items) ?? [],
+ [logQuery.data],
+ )
+ const events = React.useMemo(() => eventsFromBroadcasts(broadcasts), [broadcasts])
+
+ return (
+
+
+
+ {row.messagingSuspended && (
+
+ Suspended {formatDateTime(row.suspendedAt)}
+ {row.suspendedBy ? ` by ${row.suspendedBy.name}` : ""}. The mandatory reason is in the
+ audit log.
+
+ )}
+
+
+
+
+
+
+ )
+}
diff --git a/apps/admin/src/features/hosts/host-list-pane.tsx b/apps/admin/src/features/hosts/host-list-pane.tsx
new file mode 100644
index 0000000..c9d4848
--- /dev/null
+++ b/apps/admin/src/features/hosts/host-list-pane.tsx
@@ -0,0 +1,75 @@
+"use client"
+
+import type { AdminHostListItemDTO } from "@civfix/shared"
+
+import { Icons } from "@/components/icons"
+import { EmptyState } from "@/components/shared/page-primitives"
+import { LoadingState, ErrorState } from "@/components/shared/data-states"
+import { HostRow } from "@/features/hosts/host-row"
+import { HOST_ACTIVITY_WINDOW_DAYS } from "@/features/hosts/host-window"
+import type { useAdminHostsInfinite } from "@/features/hosts/use-hosts"
+
+type HostListQuery = ReturnType
+
+export function HostListPane({
+ listQuery,
+ rows,
+ selectedId,
+ onSelect,
+}: {
+ listQuery: HostListQuery
+ rows: AdminHostListItemDTO[]
+ selectedId: string | null
+ onSelect: (id: string) => void
+}) {
+ return (
+
+
+
Hosts
+
+ {listQuery.isSuccess && (
+
+ {rows.length}
+ {listQuery.hasNextPage ? "+" : ""}
+
+ )}
+
+
+ {listQuery.isLoading ? (
+
+ ) : listQuery.isError ? (
+ listQuery.refetch()} />
+ ) : (
+ <>
+ {rows.length === 0 ? (
+ }
+ />
+ ) : (
+ rows.map((row) => (
+ onSelect(row.host.id)}
+ />
+ ))
+ )}
+ {listQuery.hasNextPage && (
+ void listQuery.fetchNextPage()}
+ >
+ {listQuery.isFetchingNextPage ? "Loading…" : "Load more"}
+
+ )}
+ >
+ )}
+
+
+ )
+}
diff --git a/apps/admin/src/features/hosts/host-row.tsx b/apps/admin/src/features/hosts/host-row.tsx
new file mode 100644
index 0000000..6f70ab7
--- /dev/null
+++ b/apps/admin/src/features/hosts/host-row.tsx
@@ -0,0 +1,65 @@
+"use client"
+
+import type { AdminHostListItemDTO } from "@civfix/shared"
+
+import { Icons } from "@/components/icons"
+import { isKeyboardActivationKey } from "@/components/shared/keyboard-activation"
+import { formatDateTime } from "@/lib/dates"
+
+export function HostRow({
+ row,
+ selected,
+ onClick,
+}: {
+ row: AdminHostListItemDTO
+ selected: boolean
+ onClick: () => void
+}) {
+ return (
+ {
+ if (!isKeyboardActivationKey(e.key)) return
+ e.preventDefault()
+ onClick()
+ }}
+ >
+
+
+
+
+
+
+
+ {row.host.name}
+ {row.host.handle}
+ {row.messagingSuspended && (
+
+ Suspended
+
+ )}
+
+
+ {row.broadcastCount.toLocaleString()} broadcasts
+ ·
+ {row.recipientCount.toLocaleString()} recipients
+ ·
+
+ {row.eventsMessaged.toLocaleString()}{" "}
+ {row.eventsMessaged === 1 ? "event" : "events"}
+
+
+
+
+ {row.failedCount > 0 && (
+ {row.failedCount} failed
+ )}
+ {formatDateTime(row.lastBroadcastAt)}
+
+
+ )
+}
diff --git a/apps/admin/src/features/hosts/host-window.ts b/apps/admin/src/features/hosts/host-window.ts
index e87b539..ff6cf31 100644
--- a/apps/admin/src/features/hosts/host-window.ts
+++ b/apps/admin/src/features/hosts/host-window.ts
@@ -4,9 +4,13 @@ import type {
AdminHostListQuery,
} from "@civfix/shared"
+import { MINUTE_MS } from "@/lib/timing"
+
export const HOST_ACTIVITY_WINDOW_DAYS = 30
-const DAY_MS = 24 * 60 * 60 * 1000
+const DAY_MS = 24 * 60 * MINUTE_MS
+
+const UNTITLED_EVENT_TITLE = "Untitled event"
export interface HostActivityWindow {
from: string
@@ -32,9 +36,9 @@ export function hostListParams(
search: string,
windowDays: number = HOST_ACTIVITY_WINDOW_DAYS,
): Pick {
- const q = search.trim() === "" ? undefined : search.trim()
- if (!isHostFilter(filter) || filter === "all") return { q, windowDays }
- return { suspended: filter === "suspended", q, windowDays }
+ const searchTerm = search.trim() || undefined
+ if (!isHostFilter(filter) || filter === "all") return { q: searchTerm, windowDays }
+ return { suspended: filter === "suspended", q: searchTerm, windowDays }
}
export function hostBroadcastParams(
@@ -56,15 +60,15 @@ export interface HostEventRef {
export function eventsFromBroadcasts(
items: readonly AdminBroadcastListItemDTO[],
): HostEventRef[] {
- const byId = new Map()
+ const byCleanupId = new Map()
for (const item of items) {
- const existing = byId.get(item.cleanupId)
+ const existing = byCleanupId.get(item.cleanupId)
const title = item.eventTitle ?? null
if (existing === undefined) {
- byId.set(item.cleanupId, { cleanupId: item.cleanupId, title: title ?? "Untitled event" })
+ byCleanupId.set(item.cleanupId, { cleanupId: item.cleanupId, title: title ?? UNTITLED_EVENT_TITLE })
continue
}
- if (title !== null && existing.title === "Untitled event") existing.title = title
+ if (title !== null && existing.title === UNTITLED_EVENT_TITLE) existing.title = title
}
- return [...byId.values()]
+ return [...byCleanupId.values()]
}
diff --git a/apps/admin/src/features/hosts/hosts-page.tsx b/apps/admin/src/features/hosts/hosts-page.tsx
index ff5ef21..6600624 100644
--- a/apps/admin/src/features/hosts/hosts-page.tsx
+++ b/apps/admin/src/features/hosts/hosts-page.tsx
@@ -5,301 +5,97 @@ import type { AdminHostListItemDTO } from "@civfix/shared"
import { Icons } from "@/components/icons"
import { PageHead, FilterChips, EmptyState } from "@/components/shared/page-primitives"
-import { LoadingState, ErrorState } from "@/components/shared/data-states"
-import { promptDialog } from "@/components/shared/dialog"
-import { isKeyboardActivationKey } from "@/components/shared/keyboard-activation"
import { useDebounced } from "@/hooks/use-debounced"
-import { formatDateTime } from "@/lib/dates"
-import { BroadcastLog } from "@/features/hosts/broadcast-log"
+import { SEARCH_DEBOUNCE_MS } from "@/lib/timing"
+import { HostDetail } from "@/features/hosts/host-detail"
+import { HostListPane } from "@/features/hosts/host-list-pane"
import {
HOST_ACTIVITY_WINDOW_DAYS,
HOST_FILTERS,
- eventsFromBroadcasts,
hostActivityWindow,
- hostBroadcastParams,
hostListParams,
type HostActivityWindow,
+ type HostFilter,
} from "@/features/hosts/host-window"
-import {
- useAdminBroadcastsInfinite,
- useAdminHostsInfinite,
- useSetHostMessagingSuspended,
-} from "@/features/hosts/use-hosts"
-import { useNav } from "@/store/ui-store"
+import { useAdminHostsInfinite } from "@/features/hosts/use-hosts"
import type { SectionPageProps } from "@/components/shell/page-registry"
-const FILTER_LABEL: Record<(typeof HOST_FILTERS)[number], string> = {
+const FILTER_LABEL: Record = {
all: "All",
active: "Active",
suspended: "Suspended",
}
-function HostRow({
- row,
- selected,
- onClick,
-}: {
- row: AdminHostListItemDTO
- selected: boolean
- onClick: () => void
-}) {
- return (
- {
- if (!isKeyboardActivationKey(e.key)) return
- e.preventDefault()
- onClick()
- }}
- >
-
-
-
-
-
-
-
- {row.host.name}
- {row.host.handle}
- {row.messagingSuspended && (
-
- Suspended
-
- )}
-
-
- {row.broadcastCount.toLocaleString()} broadcasts
- ·
- {row.recipientCount.toLocaleString()} recipients
- ·
-
- {row.eventsMessaged.toLocaleString()}{" "}
- {row.eventsMessaged === 1 ? "event" : "events"}
-
-
-
-
- {row.failedCount > 0 && (
- {row.failedCount} failed
- )}
- {formatDateTime(row.lastBroadcastAt)}
-
-
- )
+const FILTER_OPTIONS = HOST_FILTERS.map((value) => ({ value, label: FILTER_LABEL[value] }))
+
+// Only the first load picks a host on the operator's behalf, and a deep-linked host is never
+// replaced. There is no single-host read, so a pick that a filter or search leaves out clears, and so
+// does any host that drops out of the same list after a refetch (a suspend under the Active chip): the
+// action buttons never land on a host nobody chose. Decided only on data fetched for the current params.
+function useHostSelection(
+ focusId: string | null,
+ listQuery: ReturnType,
+ rows: AdminHostListItemDTO[],
+ listKey: string,
+) {
+ const [selectedId, setSelectedId] = React.useState(focusId)
+ const [autoPick, setAutoPick] = React.useState(focusId === null)
+
+ React.useEffect(() => {
+ if (focusId) setSelectedId(focusId)
+ }, [focusId])
+ const seenIn = React.useRef<{ id: string; list: string } | null>(null)
+ React.useEffect(() => {
+ if (!listQuery.isSuccess || listQuery.isFetching) return
+ if (selectedId === null) {
+ if (autoPick && rows.length) setSelectedId(rows[0]!.host.id)
+ return
+ }
+ setAutoPick(false)
+ if (rows.some((row) => row.host.id === selectedId)) seenIn.current = { id: selectedId, list: listKey }
+ else if (selectedId !== focusId || (seenIn.current?.id === selectedId && seenIn.current.list === listKey)) {
+ setSelectedId(null)
+ }
+ }, [listQuery.isSuccess, listQuery.isFetching, rows, selectedId, focusId, listKey, autoPick])
+
+ return [selectedId, setSelectedId] as const
}
-function HostDetail({
- row,
+function HostDetailPane({
+ selected,
+ missingLink,
activityWindow,
}: {
- row: AdminHostListItemDTO
+ selected: AdminHostListItemDTO | null
+ missingLink: boolean
activityWindow: HostActivityWindow
}) {
- const suspend = useSetHostMessagingSuspended()
- const nav = useNav()
- const logParams = React.useMemo(
- () => hostBroadcastParams(row.host.id, activityWindow),
- [row.host.id, activityWindow],
- )
- const logQuery = useAdminBroadcastsInfinite(logParams)
- const broadcasts = React.useMemo(
- () => logQuery.data?.pages.flatMap((p) => p.items) ?? [],
- [logQuery.data],
- )
- const events = React.useMemo(() => eventsFromBroadcasts(broadcasts), [broadcasts])
- const suspended = row.messagingSuspended
-
- const onToggleSuspended = async () => {
- const next = !suspended
- const reason = await promptDialog({
- title: next
- ? `Suspend messaging for ${row.host.name}?`
- : `Restore messaging for ${row.host.name}?`,
- body: next
- ? "Every broadcast this host composes, schedules or sends is refused until messaging is restored. In-flight sends stop mid-chunk. The reason is written to the audit log."
- : "The host can compose and send broadcasts again, subject to the normal caps. The reason is written to the audit log.",
- label: "Reason (required)",
- placeholder: next
- ? "Repeated off-topic broadcasts to event attendees…"
- : "Reviewed with the host; the pattern has stopped…",
- confirmLabel: next ? "Suspend messaging" : "Restore messaging",
- required: true,
- danger: next,
- })
- if (reason === null || reason.trim() === "") return
- suspend.mutate({
- request: { id: row.host.id, suspended: next, reason: reason.trim() },
- hostName: row.host.name,
- })
+ if (selected) {
+ return
+ }
+ if (missingLink) {
+ return (
+ }
+ />
+ )
}
-
return (
-
-
-
-
-
-
-
-
-
{row.host.handle}
-
{row.host.name}
-
-
- {suspended ? (
- <>
- Messaging suspended
- >
- ) : (
- <>
- Messaging active
- >
- )}
-
-
-
- {suspended && (
-
- Suspended {formatDateTime(row.suspendedAt)}
- {row.suspendedBy ? ` by ${row.suspendedBy.name}` : ""}. The mandatory reason is in the
- audit log.
-
- )}
-
-
-
-
Broadcasts
-
{row.broadcastCount.toLocaleString()}
-
last {row.windowDays} days
-
-
-
Recipients
-
{row.recipientCount.toLocaleString()}
-
resolved audience
-
-
-
Sent
-
{row.sentCount.toLocaleString()}
-
delivered to a channel
-
-
0 ? "tone-alert" : ""}`}>
-
Failed
-
{row.failedCount.toLocaleString()}
-
{row.suppressedCount.toLocaleString()} suppressed
-
-
-
-
-
- Broadcast log
-
- {broadcasts.length}
-
-
-
- {logQuery.isLoading ? (
-
- ) : logQuery.isError ? (
- logQuery.refetch()}
- title="Could not load this host's broadcasts"
- />
- ) : (
- <>
-
- {logQuery.hasNextPage && (
- void logQuery.fetchNextPage()}
- >
- {logQuery.isFetchingNextPage ? "Loading…" : "Load more broadcasts"}
-
- )}
- >
- )}
-
-
-
-
-
- Events reached
-
- {row.eventsMessaged.toLocaleString()}
-
-
-
- {logQuery.isLoading ? (
-
- ) : logQuery.isError ? (
-
}
- />
- ) : events.length === 0 ? (
-
}
- />
- ) : (
-
- {events.map((event) => (
- nav("events", event.cleanupId)}
- >
- {event.title}
-
- ))}
-
- )}
-
-
-
-
-
Messaging
-
-
void onToggleSuspended()}
- >
- {suspended ? (
- <>
- Restore messaging
- >
- ) : (
- <>
- Suspend messaging
- >
- )}
-
-
-
+ }
+ />
)
}
export function HostsPage({ focusId }: SectionPageProps) {
- const [filter, setFilter] = React.useState("all")
+ const [filter, setFilter] = React.useState("all")
const [query, setQuery] = React.useState("")
- const [selId, setSelId] = React.useState(focusId)
- const [autoPick, setAutoPick] = React.useState(focusId === null)
- const debouncedQuery = useDebounced(query, 250)
+ const debouncedQuery = useDebounced(query, SEARCH_DEBOUNCE_MS)
const activityWindow = React.useMemo(() => hostActivityWindow(), [])
const listParams = React.useMemo(
() => hostListParams(filter, debouncedQuery),
@@ -307,35 +103,14 @@ export function HostsPage({ focusId }: SectionPageProps) {
)
const listQuery = useAdminHostsInfinite(listParams)
const rows = React.useMemo(
- () => listQuery.data?.pages.flatMap((p) => p.items) ?? [],
+ () => listQuery.data?.pages.flatMap((page) => page.items) ?? [],
[listQuery.data],
)
+ const [selectedId, setSelectedId] = useHostSelection(focusId, listQuery, rows, JSON.stringify(listParams))
- React.useEffect(() => {
- if (focusId) setSelId(focusId)
- }, [focusId])
- // Only the first load picks a host on the operator's behalf, and a deep-linked host is never
- // replaced. There is no single-host read, so a pick that a filter or search leaves out clears, and so
- // does any host that drops out of the same list after a refetch (a suspend under the Active chip): the
- // action buttons never land on a host nobody chose. Decided only on data fetched for the current params.
- const listKey = JSON.stringify(listParams)
- const seenIn = React.useRef<{ id: string; list: string } | null>(null)
- React.useEffect(() => {
- if (!listQuery.isSuccess || listQuery.isFetching) return
- if (selId === null) {
- if (autoPick && rows.length) setSelId(rows[0]!.host.id)
- return
- }
- setAutoPick(false)
- if (rows.some((r) => r.host.id === selId)) seenIn.current = { id: selId, list: listKey }
- else if (selId !== focusId || (seenIn.current?.id === selId && seenIn.current.list === listKey)) {
- setSelId(null)
- }
- }, [listQuery.isSuccess, listQuery.isFetching, rows, selId, focusId, listKey, autoPick])
-
- const selected = rows.find((r) => r.host.id === selId) ?? null
+ const selected = rows.find((row) => row.host.id === selectedId) ?? null
const missingLink =
- selected === null && selId !== null && selId === focusId && listQuery.isSuccess
+ selected === null && selectedId !== null && selectedId === focusId && listQuery.isSuccess
return (
<>
@@ -354,9 +129,9 @@ export function HostsPage({ focusId }: SectionPageProps) {
({ value, label: FILTER_LABEL[value] }))}
+ options={FILTER_OPTIONS}
value={filter}
- onChange={setFilter}
+ onChange={(value) => setFilter(value as HostFilter)}
/>
@@ -372,75 +147,14 @@ export function HostsPage({ focusId }: SectionPageProps) {
-
-
-
Hosts
-
- {listQuery.isSuccess && (
-
- {rows.length}
- {listQuery.hasNextPage ? "+" : ""}
-
- )}
-
-
- {listQuery.isLoading ? (
-
- ) : listQuery.isError ? (
- listQuery.refetch()} />
- ) : (
- <>
- {rows.length === 0 ? (
- }
- />
- ) : (
- rows.map((row) => (
- setSelId(row.host.id)}
- />
- ))
- )}
- {listQuery.hasNextPage && (
- void listQuery.fetchNextPage()}
- >
- {listQuery.isFetchingNextPage ? "Loading…" : "Load more"}
-
- )}
- >
- )}
-
-
+
- {selected ? (
-
- ) : missingLink ? (
- }
- />
- ) : (
- }
- />
- )}
+
>
diff --git a/apps/admin/src/features/inbox/attachments.ts b/apps/admin/src/features/inbox/attachments.ts
index f68185f..05d89a4 100644
--- a/apps/admin/src/features/inbox/attachments.ts
+++ b/apps/admin/src/features/inbox/attachments.ts
@@ -1,17 +1,20 @@
-const KB = 1024
-const MB = KB * 1024
+import { BYTES_PER_KB, BYTES_PER_MB } from "@/lib/bytes"
+import { MINUTE_MS } from "@/lib/timing"
// Attachment links are presigned GET urls the API signs for 15 minutes (MEDIA_GET_URL_TTL_SEC), so a
// reader left open re-reads its message before the links it shows go dead.
-const ATTACHMENT_URL_REFRESH_MS = 10 * 60_000
+const ATTACHMENT_URL_REFRESH_MS = 10 * MINUTE_MS
+
+const ONE_DECIMAL_BELOW_MB = 10
+const TRAILING_ZERO_DECIMAL = /\.0$/
export function formatBytes(bytes: number): string {
- if (bytes < KB) return `${bytes} B`
- if (bytes < MB) return `${Math.round(bytes / KB)} KB`
- const mb = bytes / MB
- return `${mb < 10 ? mb.toFixed(1).replace(/\.0$/, "") : Math.round(mb)} MB`
+ if (bytes < BYTES_PER_KB) return `${bytes} B`
+ if (bytes < BYTES_PER_MB) return `${Math.round(bytes / BYTES_PER_KB)} KB`
+ const mb = bytes / BYTES_PER_MB
+ return `${mb < ONE_DECIMAL_BELOW_MB ? mb.toFixed(1).replace(TRAILING_ZERO_DECIMAL, "") : Math.round(mb)} MB`
}
export function attachmentRefreshInterval(attachmentCount: number): number | false {
return attachmentCount > 0 ? ATTACHMENT_URL_REFRESH_MS : false
-}
\ No newline at end of file
+}
diff --git a/apps/admin/src/features/inbox/inbox-feed.test.ts b/apps/admin/src/features/inbox/inbox-feed.test.ts
index 966ece3..5a8e122 100644
--- a/apps/admin/src/features/inbox/inbox-feed.test.ts
+++ b/apps/admin/src/features/inbox/inbox-feed.test.ts
@@ -1,4 +1,4 @@
-import { readFileSync } from "node:fs"
+import { readdirSync, readFileSync } from "node:fs"
import { describe, expect, it } from "vitest"
import { INBOX_FEED_FILTER_LABELS, InboxFeedFilterSchema } from "@civfix/shared"
@@ -67,11 +67,13 @@ describe("reply rows", () => {
describe("inbound mail rendering", () => {
it("renders sender-controlled mail as text, never as markup", () => {
- const sources = [
- "./inbox-views.tsx",
- "../mail/mail-page.tsx",
- "../mail/mail-badges.tsx",
- ].map((path) => readFileSync(new URL(path, import.meta.url), "utf8"))
+ const sources = ["./", "../mail/"].flatMap((dir) => {
+ const url = new URL(dir, import.meta.url)
+ return readdirSync(url)
+ .filter((name) => name.endsWith(".tsx") && !name.includes(".test."))
+ .map((name) => readFileSync(new URL(name, url), "utf8"))
+ })
+ expect(sources.length).toBeGreaterThan(5)
for (const source of sources) {
expect(source).not.toMatch(/dangerouslySetInnerHTML|innerHTML|bodyHtml/)
}
diff --git a/apps/admin/src/features/inbox/inbox-views.tsx b/apps/admin/src/features/inbox/inbox-views.tsx
index e234bb6..98677e9 100644
--- a/apps/admin/src/features/inbox/inbox-views.tsx
+++ b/apps/admin/src/features/inbox/inbox-views.tsx
@@ -19,7 +19,7 @@ import { replyOriginLabel } from "@/features/inbox/inbox-feed"
import { AuthVerdictBadge, PublicationBadge } from "@/features/mail/mail-badges"
import { MAIL_STATUS_CLS, tsTitle } from "@/features/mail/mail-presentation"
-const STATUS_CLS: Record = {
+const INBOUND_STATUS_CLS: Record = {
unread: "status-flag",
read: "status-ok",
archived: "status-progress",
@@ -82,7 +82,7 @@ export function InboxRow({
{isEmail ? (
-
+
{INBOUND_EMAIL_STATUS_LABELS[item.status]}
) : item.publication ? (
@@ -101,32 +101,34 @@ export function InboxRow({
}
export function InboxReader({ id }: { id: string }) {
- const q = useInboxMessage(id)
+ const messageQuery = useInboxMessage(id)
const setStatus = useSetInboxStatus()
- if (q.isLoading) return
- if (q.isError) return q.refetch()} />
- const sel = q.data
- if (!sel) return } />
+ if (messageQuery.isLoading) return
+ if (messageQuery.isError) {
+ return messageQuery.refetch()} />
+ }
+ const message = messageQuery.data
+ if (!message) return } />
- const markRead = () => setStatus.mutate({ id: sel.id, status: "read" })
- const archive = () => setStatus.mutate({ id: sel.id, status: "archived" })
+ const markRead = () => setStatus.mutate({ id: message.id, status: "read" })
+ const archive = () => setStatus.mutate({ id: message.id, status: "archived" })
return (
-
{sel.subject || "(no subject)"}
+
{message.subject || "(no subject)"}
-
{sel.from}
+
{message.from}
·
-
to {sel.recipient}
-
+
to {message.recipient}
+
-
- {INBOUND_EMAIL_STATUS_LABELS[sel.status]}
+
+ {INBOUND_EMAIL_STATUS_LABELS[message.status]}
@@ -135,25 +137,25 @@ export function InboxReader({ id }: { id: string }) {
- {sel.bodyText || "(no plain-text body)"}
+ {message.bodyText || "(no plain-text body)"}
-
+
Mark read
Archive
diff --git a/apps/admin/src/features/mail/compose-modal.tsx b/apps/admin/src/features/mail/compose-modal.tsx
new file mode 100644
index 0000000..11993d7
--- /dev/null
+++ b/apps/admin/src/features/mail/compose-modal.tsx
@@ -0,0 +1,134 @@
+"use client"
+
+import * as React from "react"
+
+import { Icons } from "@/components/icons"
+import { usePristineDismiss } from "@/components/shared/backdrop-dismiss"
+import { useModalFocus } from "@/components/shared/modal-focus"
+
+export interface ComposeInput {
+ to: string
+ subject: string
+ body: string
+}
+
+interface ComposeModalProps {
+ open: boolean
+ onClose: () => void
+ onSend: (input: ComposeInput) => void
+ pending: boolean
+}
+
+export function ComposeModal(props: ComposeModalProps) {
+ if (!props.open) return null
+ return
+}
+
+function ComposeFoot({
+ canSend,
+ onClose,
+ onSend,
+}: {
+ canSend: boolean
+ onClose: () => void
+ onSend: () => void
+}) {
+ return (
+
+
+ From civfix, via a per-conversation reply-… address
+
+
+
+ Cancel
+
+
+ Send
+
+
+ )
+}
+
+function ComposeEditor({ onClose, onSend, pending }: ComposeModalProps) {
+ const [to, setTo] = React.useState("")
+ const [subject, setSubject] = React.useState("")
+ const [body, setBody] = React.useState("")
+ const modalRef = useModalFocus(true)
+ const toRef = React.useRef(null)
+ const titleId = React.useId()
+ const toId = React.useId()
+ const subjectId = React.useId()
+ const bodyId = React.useId()
+
+ // Focused here rather than with autoFocus: autoFocus lands before useModalFocus records the element
+ // to restore, so closing would return focus to the dead field instead of the Compose button.
+ React.useEffect(() => {
+ toRef.current?.focus()
+ }, [])
+
+ const backdrop = usePristineDismiss(onClose, to === "" && subject === "" && body === "")
+
+ const canSend = !!to.trim() && !!subject.trim() && !!body.trim() && !pending
+
+ return (
+
+
+
+
New message
+
+
+
+
+
+
+ To
+ setTo(e.target.value)}
+ />
+
+
+ Subject
+ setSubject(e.target.value)}
+ />
+
+
+ Message
+
+
+
onSend({ to: to.trim(), subject: subject.trim(), body: body.trim() })}
+ />
+
+
+ )
+}
diff --git a/apps/admin/src/features/mail/forward-template-insert.test.ts b/apps/admin/src/features/mail/forward-template-insert.test.ts
new file mode 100644
index 0000000..a41ceb2
--- /dev/null
+++ b/apps/admin/src/features/mail/forward-template-insert.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from "vitest"
+
+import { insertAt } from "./forward-template-modal-state"
+
+describe("inserting a template variable", () => {
+ it("inserts into an empty field", () => {
+ expect(insertAt("", 0, 0, "{title}")).toEqual({ value: "{title}", caret: 7 })
+ })
+
+ it("inserts at the caret in the middle of the text", () => {
+ expect(insertAt("Report here", 7, 7, "{title}")).toEqual({
+ value: "Report {title} here",
+ caret: 14,
+ })
+ })
+
+ it("replaces the selected text", () => {
+ expect(insertAt("Report NAME here", 7, 11, "{title}")).toEqual({
+ value: "Report {title} here",
+ caret: 14,
+ })
+ })
+})
diff --git a/apps/admin/src/features/mail/forward-template-modal-state.ts b/apps/admin/src/features/mail/forward-template-modal-state.ts
index 591adcd..48479d6 100644
--- a/apps/admin/src/features/mail/forward-template-modal-state.ts
+++ b/apps/admin/src/features/mail/forward-template-modal-state.ts
@@ -20,3 +20,12 @@ export function toStoredTemplate(value: string): string | null {
const trimmed = value.trim()
return trimmed === "" ? null : trimmed
}
+
+export function insertAt(
+ value: string,
+ start: number,
+ end: number,
+ token: string,
+): { value: string; caret: number } {
+ return { value: value.slice(0, start) + token + value.slice(end), caret: start + token.length }
+}
diff --git a/apps/admin/src/features/mail/forward-template-modal.tsx b/apps/admin/src/features/mail/forward-template-modal.tsx
index e52f649..893e0bf 100644
--- a/apps/admin/src/features/mail/forward-template-modal.tsx
+++ b/apps/admin/src/features/mail/forward-template-modal.tsx
@@ -17,6 +17,7 @@ import { useModalFocus } from "@/components/shared/modal-focus"
import { errorMessage } from "@/lib/error-messages"
import { usePreviewForwardTemplate } from "@/features/mail/use-mail"
import {
+ insertAt,
resolveTemplateSeed,
toStoredTemplate,
type ForwardTemplateInitial,
@@ -29,7 +30,7 @@ const SOURCE_LABEL: Record = {
builtin: "the built-in template",
}
-export interface ForwardTemplateModalProps {
+interface ForwardTemplateModalProps {
open: boolean
onClose: () => void
title: string
@@ -59,51 +60,248 @@ function IssueList({ issues }: { issues: ForwardTemplateIssue[] }) {
)
}
-function ForwardTemplateEditor({
- onClose,
- title,
+function TemplatePreview({ rendered }: { rendered: PreviewForwardTemplateResponse }) {
+ return (
+
+
+
+ Rendered with sample data · subject from {SOURCE_LABEL[rendered.subjectSource]} · body
+ from {SOURCE_LABEL[rendered.bodySource]}
+
+
+ )
+}
+
+type TemplateField = "subject" | "body"
+
+interface TemplateFieldsProps {
+ subtitle: string
+ fallback: ForwardTemplatePair
+ fallbackLabel: string
+ subject: string
+ body: string
+ subjectId: string
+ bodyId: string
+ subjectIssues: ForwardTemplateIssue[]
+ bodyIssues: ForwardTemplateIssue[]
+ onSubjectChange: (value: string) => void
+ onBodyChange: (value: string) => void
+ subjectRef: React.RefObject
+ bodyRef: React.RefObject
+ onFieldFocus: (field: TemplateField) => void
+ onInsertToken: (token: string) => void
+ preview: { isPending: boolean; isError: boolean; error: unknown }
+}
+
+function TemplateFields({
subtitle,
- initial,
fallback,
fallbackLabel,
- onSave,
- pending,
-}: ForwardTemplateModalProps) {
- const preview = usePreviewForwardTemplate()
+ subject,
+ body,
+ subjectId,
+ bodyId,
+ subjectIssues,
+ bodyIssues,
+ onSubjectChange,
+ onBodyChange,
+ subjectRef,
+ bodyRef,
+ onFieldFocus,
+ onInsertToken,
+ preview,
+}: TemplateFieldsProps) {
+ return (
+
+
{subtitle}
+
+ {FORWARD_TEMPLATE_VARIABLES.map((v) => (
+ e.preventDefault()}
+ onClick={() => onInsertToken(v.token)}
+ >
+ {v.token}
+
+ ))}
+
+
Click a variable to insert it where your cursor is.
+
+
+ Subject
+ onFieldFocus("subject")}
+ onChange={(e) => onSubjectChange(e.target.value)}
+ />
+
+
+
+
+ Body
+
+
+ {preview.isPending &&
Rendering a preview…
}
+ {preview.isError && !preview.isPending && (
+
+ {errorMessage(preview.error)}
+
+ )}
+
+ )
+}
+
+function useTemplateDraft(initial: ForwardTemplateInitial, fallback: ForwardTemplatePair) {
const [seed] = React.useState(() => resolveTemplateSeed(initial, fallback))
const [subject, setSubject] = React.useState(seed.subject)
const [body, setBody] = React.useState(seed.body)
- const [rendered, setRendered] = React.useState(null)
- const modalRef = useModalFocus(true)
- const titleId = React.useId()
-
const subjectRef = React.useRef(null)
const bodyRef = React.useRef(null)
- const focusedField = React.useRef<"subject" | "body">("body")
- const subjectId = React.useId()
- const bodyId = React.useId()
+ const focusedField = React.useRef("body")
const insertToken = (token: string) => {
const field = focusedField.current
const el = field === "subject" ? subjectRef.current : bodyRef.current
const value = field === "subject" ? subject : body
const setValue = field === "subject" ? setSubject : setBody
- const start = el?.selectionStart ?? value.length
- const end = el?.selectionEnd ?? value.length
- setValue(value.slice(0, start) + token + value.slice(end))
+ const next = insertAt(
+ value,
+ el?.selectionStart ?? value.length,
+ el?.selectionEnd ?? value.length,
+ token,
+ )
+ setValue(next.value)
requestAnimationFrame(() => {
if (!el) return
el.focus()
- const caret = start + token.length
- el.setSelectionRange(caret, caret)
+ el.setSelectionRange(next.caret, next.caret)
})
}
+ const fill = (next: ForwardTemplatePair) => {
+ setSubject(next.subject)
+ setBody(next.body)
+ }
+
+ return {
+ subject,
+ setSubject,
+ body,
+ setBody,
+ pristine: subject === seed.subject && body === seed.body,
+ fill,
+ subjectRef,
+ bodyRef,
+ focusField: (field: TemplateField) => (focusedField.current = field),
+ insertToken,
+ }
+}
+
+function TemplateModalFoot({
+ previewing,
+ fallback,
+ fallbackLabel,
+ onBack,
+ onFill,
+ previewDisabled,
+ onPreview,
+ onClose,
+ saveDisabled,
+ onSave,
+}: {
+ previewing: boolean
+ fallback: ForwardTemplatePair
+ fallbackLabel: string
+ onBack: () => void
+ onFill: (next: ForwardTemplatePair) => void
+ previewDisabled: boolean
+ onPreview: () => void
+ onClose: () => void
+ saveDisabled: boolean
+ onSave: () => void
+}) {
+ return (
+
+ {previewing ? (
+
+ Back to editor
+
+ ) : (
+ <>
+
onFill(fallback)}>
+ Copy in the {fallbackLabel}
+
+
onFill({ subject: "", body: "" })}>
+ Clear
+
+ >
+ )}
+
+ {!previewing && (
+
+ Preview
+
+ )}
+
+ Cancel
+
+
+ Save
+
+
+ )
+}
+
+function ForwardTemplateEditor({
+ onClose,
+ title,
+ subtitle,
+ initial,
+ fallback,
+ fallbackLabel,
+ onSave,
+ pending,
+}: ForwardTemplateModalProps) {
+ const preview = usePreviewForwardTemplate()
+ const draft = useTemplateDraft(initial, fallback)
+ const { subject, body } = draft
+ const [rendered, setRendered] = React.useState(null)
+ const modalRef = useModalFocus(true)
+ const titleId = React.useId()
+ const subjectId = React.useId()
+ const bodyId = React.useId()
+
const subjectIssues = forwardTemplateIssues(subject)
const bodyIssues = forwardTemplateIssues(body)
const hasIssues = subjectIssues.length > 0 || bodyIssues.length > 0
- const backdrop = usePristineDismiss(onClose, subject === seed.subject && body === seed.body)
+ const backdrop = usePristineDismiss(onClose, draft.pristine)
const onPreview = () => {
const input: PreviewForwardTemplateRequest = {}
@@ -129,124 +327,40 @@ function ForwardTemplateEditor({
{rendered ? (
-
-
-
- Rendered with sample data · subject from {SOURCE_LABEL[rendered.subjectSource]} · body
- from {SOURCE_LABEL[rendered.bodySource]}
-
-
+
) : (
-
-
{subtitle}
-
- {FORWARD_TEMPLATE_VARIABLES.map((v) => (
- e.preventDefault()}
- onClick={() => insertToken(v.token)}
- >
- {v.token}
-
- ))}
-
-
Click a variable to insert it where your cursor is.
-
-
- Subject
- (focusedField.current = "subject")}
- onChange={(e) => setSubject(e.target.value)}
- />
-
-
-
-
- Body
-
-
- {preview.isPending &&
Rendering a preview…
}
- {preview.isError && !preview.isPending && (
-
- {errorMessage(preview.error)}
-
- )}
-
+
)}
-
- {rendered ? (
-
setRendered(null)}>
- Back to editor
-
- ) : (
- <>
-
{
- setSubject(fallback.subject)
- setBody(fallback.body)
- }}
- >
- Copy in the {fallbackLabel}
-
-
{
- setSubject("")
- setBody("")
- }}
- >
- Clear
-
- >
- )}
-
- {!rendered && (
-
- Preview
-
- )}
-
- Cancel
-
-
- onSave({ subject: toStoredTemplate(subject), body: toStoredTemplate(body) })
- }
- >
- Save
-
-
+ setRendered(null)}
+ onFill={draft.fill}
+ previewDisabled={hasIssues || preview.isPending}
+ onPreview={onPreview}
+ onClose={onClose}
+ saveDisabled={hasIssues || pending}
+ onSave={() => onSave({ subject: toStoredTemplate(subject), body: toStoredTemplate(body) })}
+ />