v1.0

ActivityFeed

Preview

Basic

Loading…

Preview

Code

ts
import ActivityFeed from '@/components/data/ActivityFeed';

src/components/data/ActivityFeed.tsx

AI prompt

text
Build a recent-activity feed (audit trail list) component in React + TypeScript + Tailwind CSS.

## Look
- A house panel with `p-5 space-y-3 h-fit`. Header: a 14px lucide `Activity` icon in slate-400 beside the title "Recent activity" (10px semibold uppercase wide-tracking slate-500 / dark slate-400).
- An ordered list, `space-y-2.5`. Each row is `flex gap-2.5 text-[11px]`:
  - A 6px round dot (`mt-1.5 shrink-0`) coloured by event type.
  - Line 1, slate-700 / dark slate-200: the event's phrase, then the reference (e.g. "REQ-4471") in mono semibold indigo-600 / dark indigo-400, when there is one.
  - Line 2, slate-400 / dark slate-500, truncating: the timestamp as `YYYY-MM-DD HH:MM` (the ISO string's first 16 characters with the T replaced by a space, no timezone conversion), then " · <actor>", or " · system" when there is no actor.

## Behaviour
- Event types are dotted strings (`request.created`, `bo.approved`, `auth.failed`). A label map turns each into a phrase: "Request created", "Approved in the BO", "Failed sign-in" and so on. An unmapped type shows its raw string rather than hiding the row.
- Dot tones, first match wins: approved → emerald-500. Rejected or failed (`bo.rejected`, `run.failed`, `auth.failed`) → rose-500. Needs a human (`bo.partial`, `bo.unresolved`) → amber-500. Any `request.*` → indigo-500. Any other `bo.*` → violet-500. Everything else → slate-300 / dark slate-600.
- Admin events (types starting `auth.`, `user.`, `role.`, `menu.`) are hidden unless `isSuperUser` is true.
- Rows are shown in the order given (newest first).
- Empty: a 12px slate-400 paragraph: "Nothing recorded yet. Every release, submission and approval lands here — this feed is the audit trail, not a summary of one."

## API
`events: { id: number; type: string; createdAt: string; actor: string | null; ref: string | null; payload: Record<string, unknown> | null }[]`, `isSuperUser: boolean`. Export the `FeedEvent` type.

## Demo
Three events on 2026-08-31 / 08-30: Grace Hopper approved REQ-4471, Alan Turing submitted REQ-4470, Ada Lovelace's REQ-4468 rejected. Add one `auth.failed` row to show the super-user filter.

## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.

Source

tsx
/* Origin: bonus-adjustment (96S2), verbatim. */
import { Activity } from 'lucide-react';
import { cn } from '@/lib/cn';
import { eventLabel, eventTone, isAdminEvent } from '@/lib/events';

/**
 * The events feed — which is also the audit trail. With Lark retired this table
 * is the only durable record that a payout happened (see the schema's note on
 * `Event`), so the dashboard shows it rather than a decorative activity list.
 *
 * Labels, tones and the admin/ordinary split come from `lib/events` so this and
 * the header's notification bell always describe a row the same way.
 */
export type FeedEvent = {
  id: number;
  type: string;
  createdAt: string;
  actor: string | null;
  ref: string | null;
  payload: Record<string, unknown> | null;
};

export default function ActivityFeed({
  events,
  isSuperUser,
}: {
  events: FeedEvent[];
  isSuperUser: boolean;
}) {
  // Ordinary users see the adjustment trail; administrators see everything,
  // including the auth events that only matter to them.
  const visible = isSuperUser ? events : events.filter((e) => !isAdminEvent(e.type));

  return (
    <div className="panel p-5 space-y-3 h-fit">
      <div className="flex items-center gap-2">
        <Activity className="w-3.5 h-3.5 text-slate-400" />
        <h2 className="panel-title">Recent activity</h2>
      </div>

      {visible.length === 0 ? (
        <p className="text-xs text-slate-400 dark:text-slate-500">
          Nothing recorded yet. Every release, submission and approval lands here — this feed is the
          audit trail, not a summary of one.
        </p>
      ) : (
        <ol className="space-y-2.5">
          {visible.map((event) => (
            <li key={event.id} className="flex gap-2.5 text-[11px]">
              <span className={cn('w-1.5 h-1.5 rounded-full mt-1.5 shrink-0', eventTone(event.type))} />
              <div className="min-w-0">
                <div className="text-slate-700 dark:text-slate-200">
                  {eventLabel(event.type)}
                  {event.ref && (
                    <span className="font-mono font-semibold text-indigo-600 dark:text-indigo-400">
                      {' '}
                      {event.ref}
                    </span>
                  )}
                </div>
                <div className="text-slate-400 dark:text-slate-500 truncate">
                  {event.createdAt.slice(0, 16).replace('T', ' ')}
                  {event.actor ? ` · ${event.actor}` : ' · system'}
                </div>
              </div>
            </li>
          ))}
        </ol>
      )}
    </div>
  );
}

Props

PropTypeDefaultDescription
events*FeedEvent[]—
isSuperUser*boolean—