Aktivitätsprotokoll für Apps
Aktivitäts-Screens für Apps. Zeitleiste nach Tagen mit Filtern, Prüftabelle mit vorher und nachher, Verlauf eines Datensatzes mit Wiederherstellen, Seitenfeed und Zusammenfassung.
Wer was wann geändert hat
Ein Aktivitätsprotokoll macht gemeinsame Arbeit nachvollziehbar. Wenn mehrere Menschen dieselben Datensätze bearbeiten, zeigt es, wer was wann geändert hat, und ein alter Wert lässt sich zurückholen, statt herumzufragen.
Die „Zeitleiste“ setzt alles nach Tagen auf eine Linie, gefiltert nach Person, Art, Zeitraum und Wörtern, Kommentare zitiert und Änderungen als von und nach. Die „Prüftabelle“ zeigt, wer was wo und wann getan hat, sortierbar und geblättert, jede Zeile öffnet vorher und nachher mit Gerät und Adresse, die Ansicht geht als CSV raus. Der „Verlauf eines Datensatzes“ stellt die aktuellen Werte neben jede Änderung Feld für Feld, ein alter Wert kommt mit einem Klick zurück. Der „Seitenfeed“ ist ein schmaler Feed neben der Arbeit mit Marke für Neues und Reiter für das, was dich betrifft. Die „Zusammenfassung“ vergleicht Änderungen, Kommentare, Neues und aktive Personen je Tag oder Woche mit Diagramm, den aktivsten Personen und meistbearbeiteten Dingen.
Schreib Einträge als einfache Sätze mit der Person vorn, Lena hat die Karte in die Prüfung verschoben. Gib die Prüftabelle den Admins und den Seitenfeed allen anderen. Zeig bei jeder Änderung von und nach, ein bloßes geändert sagt niemandem etwas.
Variants
Verlauf eines DatensatzesNeu in 1.2
Die aktuellen Werte eines Datensatzes neben jeder Änderung Feld für Feld, nach Feld gefiltert, und ein alter Wert mit einem Klick zurück.
Installv1.0.0
aiw add @ai-website/activity-logBringt mit utils, kit, avatar, bar-chart, button, icon, icon-button, motion, reveal, sidebar
Usage
<ActivityLog variant="record-history" title="Deal history" record={{ title, fields: [{ id, label, value }] }} events={[{ …, changes: [{ field, from, to }] }]} />Details
Was Aktivitätsprotokoll mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Team und Zusammenarbeit
- Bereich
- App
- Variants
- 5
- Version
- 1.0.0 · seit 1.2.0
- Datei
- components/ui/activity-log.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 84 eigene Texte
- Studio
- Im Code anpassen
- In Sammlungen
- App-Dashboard
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle ActivityLogProps aus components/ui/activity-log.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalActivityLogVarianttimelineaudit-tablerecord-historycompact-feeddigest
sidebaroptionalOmit<SidebarProps, 'children'> | nullThe app frame. Leave it out (or null) for a standalone screen.
titlePflichtstringsubtitleoptionalstringnowoptionalstringISO date and time that counts as now
meoptionalstringYour person id, written as "You"
peoplePflichtActivityPerson[]eventsPflichtActivityEvent[]moreoptionalActivityEvent[]compact-feed: older events that "Load more" brings in, in pages
recordoptionalActivityRecordrecord-history: the record the changes belong to
pageSizeoptionalnumberaudit-table: rows per page
childrenoptionalReact.ReactNodecompact-feed: the page beside the feed on desktops
onActionoptional(action: string, data: Record<string, unknown>) => void | Promise<void>Called on export, restore, load and seen. Without it the block simulates the request.
Eingebaute Texte
Diese Texte schreibt Aktivitätsprotokoll selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 84 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| search | Englisch Search activity | Deutsch Aktivitäten durchsuchen |
| person | Englisch Person | Deutsch Person |
| everyone | Englisch Everyone | Deutsch Alle Personen |
| kind | Englisch Kind | Deutsch Art |
| allKinds | Englisch Every kind | Deutsch Alle Arten |
| period | Englisch Period | Deutsch Zeitraum |
| day | Englisch 24 hours | Deutsch 24 Stunden |
| week | Englisch 7 days | Deutsch 7 Tage |
| month | Englisch 30 days | Deutsch 30 Tage |
| all | Englisch All | Deutsch Alle |
| create | Englisch Created | Deutsch Angelegt |
| update | Englisch Changed | Deutsch Geändert |
| delete | Englisch Deleted | Deutsch Gelöscht |
| comment | Englisch Comments | Deutsch Kommentare |
| share | Englisch Shared | Deutsch Geteilt |
| upload | Englisch Uploads | Deutsch Uploads |
| status | Englisch Status | Deutsch Status |
| login | Englisch Sign ins | Deutsch Anmeldungen |
| invite | Englisch Invites | Deutsch Einladungen |
| clear | Englisch Clear filters | Deutsch Filter zurücksetzen |
| results | Englisch {n} activities | Deutsch {n} Aktivitäten |
| oneResult | Englisch 1 activity | Deutsch 1 Aktivität |
| none | Englisch Nothing happened in this view. Widen the period or clear the filters. | Deutsch In dieser Ansicht ist nichts passiert. Erweitere den Zeitraum oder setze die Filter zurück. |
| today | Englisch Today | Deutsch Heute |
| yesterday | Englisch Yesterday | Deutsch Gestern |
| you | Englisch You | Deutsch Du |
| changedTo | Englisch changed to | Deutsch geändert auf |
| time | Englisch Time | Deutsch Zeit |
| actor | Englisch Who | Deutsch Wer |
| action | Englisch Action | Deutsch Aktion |
| object | Englisch Object | Deutsch Objekt |
| place | Englisch Where | Deutsch Wo |
| sortBy | Englisch Sort by {col} | Deutsch Nach {col} sortieren |
| details | Englisch Details of {what} | Deutsch Details zu {what} |
| before | Englisch Before | Deutsch Vorher |
| after | Englisch After | Deutsch Nachher |
| field | Englisch Field | Deutsch Feld |
| empty | Englisch empty | Deutsch leer |
| device | Englisch Device | Deutsch Gerät |
| ip | Englisch IP address | Deutsch IP-Adresse |
| eventId | Englisch Event | Deutsch Ereignis |
| export | Englisch Export CSV | Deutsch Als CSV exportieren |
| exported | Englisch {n} rows exported as CSV. | Deutsch {n} Zeilen als CSV exportiert. |
| page | Englisch Page {n} of {total} | Deutsch Seite {n} von {total} |
| showing | Englisch {from} to {to} of {total} | Deutsch {from} bis {to} von {total} |
| prev | Englisch Previous page | Deutsch Vorherige Seite |
| next | Englisch Next page | Deutsch Nächste Seite |
| history | Englisch History | Deutsch Verlauf |
| current | Englisch Current values | Deutsch Aktuelle Werte |
| fields | Englisch Fields | Deutsch Felder |
| allFields | Englisch All fields | Deutsch Alle Felder |
| changedN | Englisch changed {n} fields | Deutsch hat {n} Felder geändert |
| changedOne | Englisch changed {field} | Deutsch hat {field} geändert |
| restore | Englisch Restore {value} | Deutsch {value} wiederherstellen |
| restored | Englisch {field} is back to {value}. | Deutsch {field} ist wieder {value}. |
| restoredAction | Englisch restored | Deutsch hat wiederhergestellt |
| showRecord | Englisch Show current values | Deutsch Aktuelle Werte zeigen |
| hideRecord | Englisch Hide current values | Deutsch Aktuelle Werte ausblenden |
| activity | Englisch Activity | Deutsch Aktivität |
| mentions | Englisch For you | Deutsch Für dich |
| markSeen | Englisch Mark all as seen | Deutsch Alle als gesehen markieren |
| seen | Englisch Everything is seen. | Deutsch Alles gesehen. |
| newSince | Englisch New since your last visit | Deutsch Neu seit deinem letzten Besuch |
| loadMore | Englisch Load more | Deutsch Mehr laden |
| loading | Englisch Loading… | Deutsch Wird geladen… |
| loaded | Englisch {n} more activities. | Deutsch {n} weitere Aktivitäten. |
| end | Englisch That is everything from the last weeks. | Deutsch Das ist alles aus den letzten Wochen. |
| digest | Englisch Summary | Deutsch Zusammenfassung |
| daily | Englisch Day | Deutsch Tag |
| weekly | Englisch Week | Deutsch Woche |
| prevPeriod | Englisch Earlier period | Deutsch Früherer Zeitraum |
| nextPeriod | Englisch Later period | Deutsch Späterer Zeitraum |
| changes | Englisch Changes | Deutsch Änderungen |
| comments | Englisch Comments | Deutsch Kommentare |
| created | Englisch New items | Deutsch Neu angelegt |
| activePeople | Englisch Active people | Deutsch Aktive Personen |
| vsPrev | Englisch vs. period before | Deutsch zum Zeitraum davor |
| perDay | Englisch Activity per day | Deutsch Aktivität je Tag |
| perHour | Englisch Activity per hour | Deutsch Aktivität je Stunde |
| top | Englisch Most active | Deutsch Am aktivsten |
| hot | Englisch Most worked on | Deutsch Am meisten bearbeitet |
| nEvents | Englisch {n} activities | Deutsch {n} Aktivitäten |
| noData | Englisch No activity in this period. | Deutsch In diesem Zeitraum gab es keine Aktivität. |
| weekOf | Englisch Week of {date} | Deutsch Woche ab {date} |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Aktivitätsprotokoll reagiert selbst auf die Tasten Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts und Leertaste.
- Setzt aria-checked, aria-controls, aria-expanded, aria-label, aria-labelledby, aria-live, aria-pressed und aria-sort, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen group, radio, radiogroup und status.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Jedes Feld hat ein Label, sichtbar oder für Screenreader.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 1.246 Zeilen
'use client';
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { cn, type Locale, prefersReducedMotion, useLocale, useTexts } from '@/lib/kit';
import { Avatar } from '@/components/ui/avatar';
import { BarChart } from '@/components/ui/bar-chart';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { IconButton } from '@/components/ui/icon-button';
import { useScrollFade } from '@/components/ui/motion';
import { Reveal } from '@/components/ui/reveal';
import { Sidebar, type SidebarProps } from '@/components/ui/sidebar';
export type ActivityLogVariant = 'timeline' | 'audit-table' | 'record-history' | 'compact-feed' | 'digest';
export type ActivityKind = 'create' | 'update' | 'delete' | 'comment' | 'share' | 'upload' | 'status' | 'login' | 'invite';
export interface ActivityPerson {
id: string;
name: string;
role?: string;
}
export interface ActivityChange {
/** Field id, matched against record.fields */
field: string;
from?: string;
to?: string;
}
export interface ActivityEvent {
id: string;
/** Person id */
actor: string;
kind: ActivityKind;
/** The verb part of the sentence, e.g. "moved" or "verschob" */
action: string;
/** What it happened to, e.g. "Homepage draft" */
target?: string;
/** Kind of object, e.g. "Task" */
targetType?: string;
/** ISO date and time */
time: string;
/** A comment or a note, quoted under the sentence */
detail?: string;
changes?: ActivityChange[];
ip?: string;
location?: string;
device?: string;
/** compact-feed: not seen yet */
unread?: boolean;
}
export interface ActivityRecord {
title: string;
/** e.g. "Deal" */
type?: string;
fields: { id: string; label: string; value: string }[];
}
export interface ActivityLogProps {
variant?: ActivityLogVariant;
/** The app frame. Leave it out (or null) for a standalone screen. */
sidebar?: Omit<SidebarProps, 'children'> | null;
title: string;
subtitle?: string;
/** ISO date and time that counts as now */
now?: string;
/** Your person id, written as "You" */
me?: string;
people: ActivityPerson[];
events: ActivityEvent[];
/** compact-feed: older events that "Load more" brings in, in pages */
more?: ActivityEvent[];
/** record-history: the record the changes belong to */
record?: ActivityRecord;
/** audit-table: rows per page */
pageSize?: number;
/** compact-feed: the page beside the feed on desktops */
children?: React.ReactNode;
/** Called on export, restore, load and seen. Without it the block simulates the request. */
onAction?: (action: string, data: Record<string, unknown>) => void | Promise<void>;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
const TEXTS = {
en: {
search: 'Search activity',
person: 'Person',
everyone: 'Everyone',
kind: 'Kind',
allKinds: 'Every kind',
period: 'Period',
day: '24 hours',
week: '7 days',
month: '30 days',
all: 'All',
create: 'Created',
update: 'Changed',
delete: 'Deleted',
comment: 'Comments',
share: 'Shared',
upload: 'Uploads',
status: 'Status',
login: 'Sign ins',
invite: 'Invites',
clear: 'Clear filters',
results: '{n} activities',
oneResult: '1 activity',
none: 'Nothing happened in this view. Widen the period or clear the filters.',
today: 'Today',
yesterday: 'Yesterday',
you: 'You',
changedTo: 'changed to',
time: 'Time',
actor: 'Who',
action: 'Action',
object: 'Object',
place: 'Where',
sortBy: 'Sort by {col}',
details: 'Details of {what}',
before: 'Before',
after: 'After',
field: 'Field',
empty: 'empty',
device: 'Device',
ip: 'IP address',
eventId: 'Event',
export: 'Export CSV',
exported: '{n} rows exported as CSV.',
page: 'Page {n} of {total}',
showing: '{from} to {to} of {total}',
prev: 'Previous page',
next: 'Next page',
history: 'History',
current: 'Current values',
fields: 'Fields',
allFields: 'All fields',
changedN: 'changed {n} fields',
changedOne: 'changed {field}',
restore: 'Restore {value}',
restored: '{field} is back to {value}.',
restoredAction: 'restored',
showRecord: 'Show current values',
hideRecord: 'Hide current values',
activity: 'Activity',
mentions: 'For you',
markSeen: 'Mark all as seen',
seen: 'Everything is seen.',
newSince: 'New since your last visit',
loadMore: 'Load more',
loading: 'Loading…',
loaded: '{n} more activities.',
end: 'That is everything from the last weeks.',
digest: 'Summary',
daily: 'Day',
weekly: 'Week',
prevPeriod: 'Earlier period',
nextPeriod: 'Later period',
changes: 'Changes',
comments: 'Comments',
created: 'New items',
activePeople: 'Active people',
vsPrev: 'vs. period before',
perDay: 'Activity per day',
perHour: 'Activity per hour',
top: 'Most active',
hot: 'Most worked on',
nEvents: '{n} activities',
noData: 'No activity in this period.',
weekOf: 'Week of {date}',
},
de: {
search: 'Aktivitäten durchsuchen',
person: 'Person',
everyone: 'Alle Personen',
kind: 'Art',
allKinds: 'Alle Arten',
period: 'Zeitraum',
day: '24 Stunden',
week: '7 Tage',
month: '30 Tage',
all: 'Alle',
create: 'Angelegt',
update: 'Geändert',
delete: 'Gelöscht',
comment: 'Kommentare',
share: 'Geteilt',
upload: 'Uploads',
status: 'Status',
login: 'Anmeldungen',
invite: 'Einladungen',
clear: 'Filter zurücksetzen',
results: '{n} Aktivitäten',
oneResult: '1 Aktivität',
none: 'In dieser Ansicht ist nichts passiert. Erweitere den Zeitraum oder setze die Filter zurück.',
today: 'Heute',
yesterday: 'Gestern',
you: 'Du',
changedTo: 'geändert auf',
time: 'Zeit',
actor: 'Wer',
action: 'Aktion',
object: 'Objekt',
place: 'Wo',
sortBy: 'Nach {col} sortieren',
details: 'Details zu {what}',
before: 'Vorher',
after: 'Nachher',
field: 'Feld',
empty: 'leer',
device: 'Gerät',
ip: 'IP-Adresse',
eventId: 'Ereignis',
export: 'Als CSV exportieren',
exported: '{n} Zeilen als CSV exportiert.',
page: 'Seite {n} von {total}',
showing: '{from} bis {to} von {total}',
prev: 'Vorherige Seite',
next: 'Nächste Seite',
history: 'Verlauf',
current: 'Aktuelle Werte',
fields: 'Felder',
allFields: 'Alle Felder',
changedN: 'hat {n} Felder geändert',
changedOne: 'hat {field} geändert',
restore: '{value} wiederherstellen',
restored: '{field} ist wieder {value}.',
restoredAction: 'hat wiederhergestellt',
showRecord: 'Aktuelle Werte zeigen',
hideRecord: 'Aktuelle Werte ausblenden',
activity: 'Aktivität',
mentions: 'Für dich',
markSeen: 'Alle als gesehen markieren',
seen: 'Alles gesehen.',
newSince: 'Neu seit deinem letzten Besuch',
loadMore: 'Mehr laden',
loading: 'Wird geladen…',
loaded: '{n} weitere Aktivitäten.',
end: 'Das ist alles aus den letzten Wochen.',
digest: 'Zusammenfassung',
daily: 'Tag',
weekly: 'Woche',
prevPeriod: 'Früherer Zeitraum',
nextPeriod: 'Späterer Zeitraum',
changes: 'Änderungen',
comments: 'Kommentare',
created: 'Neu angelegt',
activePeople: 'Aktive Personen',
vsPrev: 'zum Zeitraum davor',
perDay: 'Aktivität je Tag',
perHour: 'Aktivität je Stunde',
top: 'Am aktivsten',
hot: 'Am meisten bearbeitet',
nEvents: '{n} Aktivitäten',
noData: 'In diesem Zeitraum gab es keine Aktivität.',
weekOf: 'Woche ab {date}',
},
};
type Texts = (typeof TEXTS)['en'];
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
/* ------------------------------------------------------------------ */
/* Motion */
/* ------------------------------------------------------------------ */
const CSS =
'@keyframes b5a-rise{from{opacity:0;transform:translateY(12px)}}@keyframes b5a-pop{from{opacity:0;transform:scale(.6)}}@keyframes b5a-flash{from{background-color:color-mix(in oklab,var(--primary) 18%,transparent)}}@keyframes b5a-line{from{transform:scaleY(0)}}' +
'.b5a-line{transform-origin:top}' +
'@media (prefers-reduced-motion:no-preference){.b5a-rise{animation:b5a-rise .55s var(--ease-kit) var(--b5a-d,0ms) both}.b5a-pop{animation:b5a-pop .4s cubic-bezier(.34,1.56,.64,1) var(--b5a-d,0ms) both}.b5a-flash{animation:b5a-flash 2s ease-out both}.b5a-line{animation:b5a-line 1.1s var(--ease-kit) .1s both}}';
function Style() {
return (
<style href="kitb5-activity-log" precedence="default">
{CSS}
</style>
);
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
const MIN = 60000;
const DAY = 1440 * MIN;
const toMs = (iso: string) => Date.parse(iso.length <= 10 ? iso + 'T00:00:00Z' : iso + (iso.endsWith('Z') ? '' : 'Z'));
const intl = (l: Locale) => (l === 'de' ? 'de-DE' : 'en-US');
const dayKey = (iso: string) => iso.slice(0, 10);
const isoOf = (ms: number) => new Date(ms).toISOString().slice(0, 16);
const wait = (ms: number) => new Promise((r) => setTimeout(r, prefersReducedMotion() ? 0 : ms));
async function run(onAction: ActivityLogProps['onAction'], action: string, data: Record<string, unknown>, ms = 700) {
if (onAction) await onAction(action, data);
else await wait(ms);
}
function ago(iso: string, now: string, l: Locale) {
const rtf = new Intl.RelativeTimeFormat(intl(l), { numeric: 'auto', style: 'long' });
const m = Math.round((toMs(iso) - toMs(now)) / MIN);
const a = Math.abs(m);
if (a < 1) return rtf.format(0, 'minute');
if (a < 60) return rtf.format(m, 'minute');
if (a < 1440) return rtf.format(Math.round(m / 60), 'hour');
if (a < 1440 * 30) return rtf.format(Math.round(m / 1440), 'day');
return rtf.format(Math.round(m / (1440 * 30)), 'month');
}
/** Short relative time for narrow columns: 5 min, 3 h, 2 d */
function agoShort(iso: string, now: string, l: Locale) {
const m = Math.max(0, Math.round((toMs(now) - toMs(iso)) / MIN));
const u = (n: number, unit: 'minute' | 'hour' | 'day') => new Intl.NumberFormat(intl(l), { style: 'unit', unit, unitDisplay: 'narrow' }).format(n);
if (m < 60) return u(Math.max(1, m), 'minute');
if (m < 1440) return u(Math.round(m / 60), 'hour');
return u(Math.round(m / 1440), 'day');
}
const clock = (iso: string, l: Locale) => new Intl.DateTimeFormat(intl(l), { hour: l === 'de' ? '2-digit' : 'numeric', minute: '2-digit', timeZone: 'UTC' }).format(new Date(toMs(iso)));
const stampFull = (iso: string, l: Locale) => new Intl.DateTimeFormat(intl(l), { day: 'numeric', month: 'short', year: 'numeric', hour: l === 'de' ? '2-digit' : 'numeric', minute: '2-digit', timeZone: 'UTC' }).format(new Date(toMs(iso)));
function dayLabel(iso: string, now: string, l: Locale, t: Texts) {
const d = Math.round((toMs(dayKey(now)) - toMs(dayKey(iso))) / DAY);
if (d === 0) return t.today;
if (d === 1) return t.yesterday;
return new Intl.DateTimeFormat(intl(l), { weekday: 'long', day: 'numeric', month: 'long', timeZone: 'UTC' }).format(new Date(toMs(iso)));
}
const KIND: Record<ActivityKind, { icon: IconName; tone: string }> = {
create: { icon: 'plus', tone: 'bg-[color-mix(in_oklab,var(--success)_15%,var(--card))] text-success-foreground' },
update: { icon: 'edit', tone: 'bg-[color-mix(in_oklab,var(--primary)_13%,var(--card))] text-[color-mix(in_oklab,var(--primary)_82%,var(--foreground))]' },
delete: { icon: 'trash', tone: 'bg-[color-mix(in_oklab,var(--destructive)_12%,var(--card))] text-destructive' },
comment: { icon: 'message-circle', tone: 'bg-surface-2 text-foreground' },
share: { icon: 'share-out', tone: 'bg-[color-mix(in_oklab,var(--primary)_13%,var(--card))] text-[color-mix(in_oklab,var(--primary)_82%,var(--foreground))]' },
upload: { icon: 'upload', tone: 'bg-surface-2 text-foreground' },
status: { icon: 'flag', tone: 'bg-[color-mix(in_oklab,var(--warning)_18%,var(--card))] text-warning-foreground' },
login: { icon: 'log-in', tone: 'bg-surface-2 text-muted-foreground' },
invite: { icon: 'user-plus', tone: 'bg-[color-mix(in_oklab,var(--success)_15%,var(--card))] text-success-foreground' },
};
const KINDS = Object.keys(KIND) as ActivityKind[];
const ring = 'outline-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-[color-mix(in_oklab,var(--primary)_70%,transparent)]';
const ringIn = 'outline-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:-outline-offset-2 focus-visible:outline-[color-mix(in_oklab,var(--primary)_70%,transparent)]';
const field = 'h-11 w-full min-w-0 rounded-lg border border-input bg-background px-3 text-base text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] duration-300 placeholder:text-subtle-foreground focus:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] sm:h-9 sm:text-[13px]';
type Ctx = Omit<ActivityLogProps, 'variant' | 'sidebar' | 'labels' | 'className'> & { t: Texts; locale: Locale; now: string; person: (id: string) => ActivityPerson; who: (id: string) => string };
function Live({ text }: { text: string }) {
return (
<p role="status" aria-live="polite" className="sr-only">
{text}
</p>
);
}
function Header({ title, subtitle, children }: { title: string; subtitle?: string; children?: React.ReactNode }) {
return (
<div className="b5a-rise flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0">
{subtitle && <p className="text-[13px] text-muted-foreground">{subtitle}</p>}
<h1 className="mt-0.5 text-balance text-[clamp(1.5rem,1.2rem+1vw,1.875rem)] font-semibold tracking-[-0.035em] text-foreground">{title}</h1>
</div>
{children && <div className="flex flex-wrap items-center gap-2">{children}</div>}
</div>
);
}
function Main({ children, className }: { children: React.ReactNode; className?: string }) {
return <main className={cn('mx-auto w-full max-w-[80rem] flex-1 p-4 sm:p-6 lg:p-8', className)}>{children}</main>;
}
function Pick({ label, value, onChange, children, className }: { label: string; value: string; onChange: (v: string) => void; children: React.ReactNode; className?: string }) {
return (
<label className={cn('relative block min-w-0', className)}>
<span className="sr-only">{label}</span>
<select value={value} onChange={(e) => onChange(e.target.value)} className={cn(field, 'cursor-pointer appearance-none truncate pe-8 hover:border-border-strong')}>
{children}
</select>
<Icon name="chevrons-up-down" size={14} className="pointer-events-none absolute end-2.5 top-1/2 -translate-y-1/2 text-subtle-foreground" />
</label>
);
}
function SearchBox({ label, value, onChange, className }: { label: string; value: string; onChange: (v: string) => void; className?: string }) {
return (
<label className={cn('relative block', className)}>
<span className="sr-only">{label}</span>
<Icon name="search" size={15} className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-subtle-foreground" />
<input type="search" value={value} onChange={(e) => onChange(e.target.value)} placeholder={label} className={cn(field, 'ps-9')} />
</label>
);
}
function Segmented<T extends string>({ options, value, onChange, label, className }: { options: { value: T; label: React.ReactNode }[]; value: T; onChange: (v: T) => void; label: string; className?: string }) {
const ref = useRef<HTMLDivElement>(null);
return (
<div ref={ref} role="radiogroup" aria-label={label} className={cn('inline-flex rounded-lg bg-muted p-1', className)}>
{options.map((o, i) => (
<button
key={o.value}
type="button"
role="radio"
aria-checked={value === o.value}
tabIndex={value === o.value ? 0 : -1}
onClick={() => onChange(o.value)}
onKeyDown={(e) => {
const d = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
if (!d) return;
e.preventDefault();
const n = (i + d + options.length) % options.length;
onChange(options[n].value);
ref.current?.querySelectorAll<HTMLButtonElement>('[role=radio]')[n]?.focus();
}}
className={cn('inline-flex h-11 flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-3 text-[13px] font-medium transition-[background-color,color,box-shadow] duration-200 sm:h-8', value === o.value ? 'bg-background text-foreground shadow-[var(--shadow-sm)] ring-1 ring-border' : 'text-muted-foreground hover:text-foreground', ringIn)}
>
{o.label}
</button>
))}
</div>
);
}
function KindDot({ kind, size = 'md', className }: { kind: ActivityKind; size?: 'sm' | 'md'; className?: string }) {
const k = KIND[kind];
return (
<span className={cn('grid shrink-0 place-items-center rounded-full ring-4 ring-background', size === 'md' ? 'size-8' : 'size-6', k.tone, className)}>
<Icon name={k.icon} size={size === 'md' ? 15 : 12} />
</span>
);
}
/** "Mia moved Homepage draft" with the names set off */
function Sentence({ e, c, className }: { e: ActivityEvent; c: Ctx; className?: string }) {
return (
<p className={cn('text-pretty text-[14px] leading-snug text-muted-foreground', className)}>
<span className="font-semibold text-foreground">{c.who(e.actor)}</span> {e.action}
{e.target && (
<>
{' '}
<span className="font-medium text-foreground">{e.target}</span>
</>
)}
</p>
);
}
function ChangeChips({ changes, c, labelOf }: { changes: ActivityChange[]; c: Ctx; labelOf?: (id: string) => string }) {
return (
<ul className="mt-2 flex flex-wrap gap-1.5">
{changes.map((ch, i) => (
<li key={i} className="inline-flex max-w-full flex-wrap items-center gap-1.5 rounded-lg border border-border bg-card px-2 py-1 text-[12.5px]">
<span className="text-muted-foreground">{labelOf ? labelOf(ch.field) : ch.field}</span>
{ch.from && <span className="text-subtle-foreground line-through decoration-[color-mix(in_oklab,var(--destructive)_60%,transparent)]">{ch.from}</span>}
<Icon name="arrow-right" size={12} className="text-subtle-foreground" />
<span className="font-medium text-foreground">{ch.to || c.t.empty}</span>
</li>
))}
</ul>
);
}
/* ------------------------------------------------------------------ */
/* timeline */
/* ------------------------------------------------------------------ */
function Timeline(c: Ctx) {
const { t, locale, now, people, events } = c;
const [q, setQ] = useState('');
const [who, setWho] = useState('all');
const [period, setPeriod] = useState<'day' | 'week' | 'month' | 'all'>('week');
const [kinds, setKinds] = useState<ActivityKind[]>([]);
const [rowRef, fade] = useScrollFade<HTMLDivElement>();
const span = { day: 1, week: 7, month: 30, all: Infinity }[period];
const used = KINDS.filter((k) => events.some((e) => e.kind === k));
const shown = events
.filter((e) => (who === 'all' || e.actor === who) && (!kinds.length || kinds.includes(e.kind)) && (toMs(now) - toMs(e.time)) / DAY <= span && (!q || `${c.who(e.actor)} ${e.action} ${e.target ?? ''} ${e.detail ?? ''}`.toLowerCase().includes(q.toLowerCase())))
.sort((a, b) => toMs(b.time) - toMs(a.time));
const days = [...new Set(shown.map((e) => dayKey(e.time)))];
const filtered = !!q || who !== 'all' || kinds.length > 0 || period !== 'week';
const count = shown.length === 1 ? t.oneResult : fill(t.results, { n: shown.length });
return (
<Main className="max-w-[60rem]">
<Header title={c.title} subtitle={c.subtitle} />
<div className="b5a-rise mt-6 grid gap-3 [--b5a-d:60ms]">
<div className="grid grid-cols-[minmax(0,1fr)] gap-3 md:grid-cols-[minmax(0,1fr)_12rem_auto]">
<SearchBox label={t.search} value={q} onChange={setQ} />
<Pick label={t.person} value={who} onChange={setWho}>
<option value="all">{t.everyone}</option>
{people.map((p) => (
<option key={p.id} value={p.id}>
{c.who(p.id)}
</option>
))}
</Pick>
<Segmented
label={t.period}
value={period}
onChange={setPeriod}
className="flex w-full md:w-auto"
options={[
{ value: 'day', label: t.day },
{ value: 'week', label: t.week },
{ value: 'month', label: t.month },
{ value: 'all', label: t.all },
]}
/>
</div>
<div ref={rowRef} role="group" aria-label={t.kind} className={cn('kit-no-scrollbar -mx-4 flex gap-1.5 overflow-x-auto px-4 py-0.5 sm:mx-0 sm:flex-wrap sm:px-0', fade)}>
{used.map((k) => {
const on = kinds.includes(k);
return (
<button key={k} type="button" aria-pressed={on} onClick={() => setKinds((l) => (on ? l.filter((x) => x !== k) : [...l, k]))} className={cn('inline-flex h-11 shrink-0 items-center gap-1.5 rounded-lg border px-3 text-[13px] font-medium transition-colors sm:h-8', on ? 'border-transparent bg-foreground text-background' : 'border-border text-muted-foreground hover:border-border-strong hover:text-foreground', ring)}>
<Icon name={KIND[k].icon} size={13} />
{t[k]}
</button>
);
})}
</div>
<div className="flex min-h-9 items-center justify-between gap-3">
<p className="text-[12.5px] tabular-nums text-muted-foreground" aria-live="polite">
{count}
</p>
{filtered && (
<Button
size="sm"
variant="ghost"
iconLeft="x"
onClick={() => {
setQ('');
setWho('all');
setKinds([]);
setPeriod('week');
}}
>
{t.clear}
</Button>
)}
</div>
</div>
<div className="mt-2 grid gap-8">
{days.map((d, di) => {
const list = shown.filter((e) => dayKey(e.time) === d);
return (
<Reveal key={d} as="div" delay={di < 2 ? di * 80 : 0}>
<section aria-labelledby={`d-${d}`}>
<h2 id={`d-${d}`} className="sticky top-14 z-[1] -mx-1 flex items-baseline gap-2 bg-background/90 px-1 py-2 backdrop-blur md:top-0">
<span className="text-[14px] font-semibold text-foreground">{dayLabel(d, now, locale, t)}</span>
<span className="text-[12.5px] tabular-nums text-subtle-foreground">{list.length === 1 ? t.oneResult : fill(t.results, { n: list.length })}</span>
</h2>
<ol className="relative mt-2">
<span aria-hidden className="b5a-line absolute bottom-4 start-4 top-4 w-px bg-border" />
{list.map((e) => (
<li key={e.id} className="group/ev relative flex gap-3.5 pb-5 last:pb-0">
<KindDot kind={e.kind} className="relative transition-transform duration-300 group-hover/ev:scale-110" />
<div className="min-w-0 flex-1 pt-1">
<div className="flex flex-col gap-0.5 sm:flex-row sm:items-baseline sm:justify-between sm:gap-4">
<Sentence e={e} c={c} />
<time dateTime={e.time} title={stampFull(e.time, locale)} className="shrink-0 text-[12.5px] tabular-nums text-subtle-foreground">
{ago(e.time, now, locale)}
</time>
</div>
{e.targetType && <p className="mt-0.5 text-[12.5px] text-subtle-foreground">{e.targetType}</p>}
{e.detail && <blockquote className="mt-2 rounded-lg border border-border bg-surface px-3.5 py-2.5 text-pretty text-[13.5px] leading-relaxed text-foreground transition-colors group-hover/ev:border-border-strong">{e.detail}</blockquote>}
{e.changes?.length ? <ChangeChips changes={e.changes} c={c} /> : null}
</div>
</li>
))}
</ol>
</section>
</Reveal>
);
})}
{!shown.length && <p className="rounded-xl border border-dashed border-border px-4 py-12 text-center text-[13.5px] text-muted-foreground">{t.none}</p>}
</div>
</Main>
);
}
/* ------------------------------------------------------------------ */
/* audit-table */
/* ------------------------------------------------------------------ */
type SortKey = 'time' | 'actor' | 'kind';
function AuditTable(c: Ctx) {
const { t, locale, now, people, events, onAction } = c;
const id = useId();
const [q, setQ] = useState('');
const [who, setWho] = useState('all');
const [kind, setKind] = useState('all');
const [sort, setSort] = useState<{ key: SortKey; dir: 'asc' | 'desc' }>({ key: 'time', dir: 'desc' });
const [open, setOpen] = useState<string | null>(events.find((e) => e.changes?.length)?.id ?? null);
const [page, setPage] = useState(0);
const [live, setLive] = useState('');
const size = c.pageSize ?? 7;
const rows = useMemo(() => {
const list = events.filter((e) => (who === 'all' || e.actor === who) && (kind === 'all' || e.kind === kind) && (!q || `${c.who(e.actor)} ${e.action} ${e.target ?? ''} ${e.ip ?? ''} ${e.location ?? ''}`.toLowerCase().includes(q.toLowerCase())));
const v = (e: ActivityEvent) => (sort.key === 'time' ? toMs(e.time) : sort.key === 'actor' ? c.who(e.actor) : t[e.kind]);
return list.sort((a, b) => {
const x = v(a);
const y = v(b);
const r = typeof x === 'number' && typeof y === 'number' ? x - y : String(x).localeCompare(String(y), intl(locale));
return sort.dir === 'asc' ? r : -r;
});
}, [events, who, kind, q, sort, c, t, locale]);
const pages = Math.max(1, Math.ceil(rows.length / size));
const p = Math.min(page, pages - 1);
const slice = rows.slice(p * size, p * size + size);
useEffect(() => setPage(0), [q, who, kind]);
const exportCsv = async () => {
const head = [t.time, t.actor, t.action, t.object, t.place, t.ip];
const esc = (s: string) => `"${s.replace(/"/g, '""')}"`;
const csv = [head, ...rows.map((e) => [e.time, c.who(e.actor), `${t[e.kind]} ${e.action}`, e.target ?? '', e.location ?? '', e.ip ?? ''])].map((r) => r.map(esc).join(',')).join('\n');
if (onAction) await onAction('export', { rows: rows.length, csv });
else {
const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }));
const a = Object.assign(document.createElement('a'), { href: url, download: `activity-${now.slice(0, 10)}.csv` });
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
setLive(fill(t.exported, { n: rows.length }));
};
const sortTh = (k: SortKey, label: string, className?: string) => (
<th scope="col" aria-sort={sort.key === k ? (sort.dir === 'asc' ? 'ascending' : 'descending') : 'none'} className={cn('py-2 font-medium', className)}>
<button type="button" onClick={() => setSort((s) => ({ key: k, dir: s.key === k && s.dir === 'desc' ? 'asc' : 'desc' }))} className={cn('-ms-1.5 inline-flex h-8 items-center gap-1 rounded-md px-1.5 transition-colors hover:bg-accent hover:text-foreground', sort.key === k && 'text-foreground', ring)}>
{label}
<Icon name={sort.key === k ? (sort.dir === 'asc' ? 'arrow-up' : 'arrow-down') : 'chevrons-up-down'} size={13} className={sort.key === k ? '' : 'text-subtle-foreground'} />
</button>
</th>
);
const detail = (e: ActivityEvent) => (
<div className="grid gap-4 bg-surface px-4 py-4 sm:px-5 md:grid-cols-[minmax(0,1fr)_16rem]">
{e.changes?.length ? (
<table className="w-full table-fixed overflow-hidden rounded-lg border border-border bg-card text-left text-[13px]">
<thead className="border-b border-border text-[12px] text-muted-foreground">
<tr>
<th scope="col" className="w-[28%] px-3 py-2 font-medium">{t.field}</th>
<th scope="col" className="px-3 py-2 font-medium">{t.before}</th>
<th scope="col" className="px-3 py-2 font-medium">{t.after}</th>
</tr>
</thead>
<tbody>
{e.changes.map((ch, i) => (
<tr key={i} className="border-b border-border last:border-b-0 align-top">
<th scope="row" className="px-3 py-2 font-medium text-foreground [overflow-wrap:anywhere]">{ch.field}</th>
<td className="px-3 py-2">
<span className="rounded bg-[color-mix(in_oklab,var(--destructive)_10%,transparent)] px-1 text-foreground line-through decoration-[color-mix(in_oklab,var(--destructive)_55%,transparent)] [overflow-wrap:anywhere]">{ch.from || t.empty}</span>
</td>
<td className="px-3 py-2">
<span className="rounded bg-[color-mix(in_oklab,var(--success)_13%,transparent)] px-1 text-foreground [overflow-wrap:anywhere]">{ch.to || t.empty}</span>
</td>
</tr>
))}
</tbody>
</table>
) : (
<Sentence e={e} c={c} className="self-center" />
)}
<dl className="grid content-start gap-2 text-[12.5px]">
{[
[t.eventId, e.id],
[t.ip, e.ip],
[t.place, e.location],
[t.device, e.device],
[t.time, stampFull(e.time, locale)],
]
.filter(([, v]) => v)
.map(([k, v]) => (
<div key={k} className="flex justify-between gap-3 border-b border-border pb-2 last:border-b-0">
<dt className="text-muted-foreground">{k}</dt>
<dd className="truncate text-right tabular-nums text-foreground">{v}</dd>
</div>
))}
</dl>
</div>
);
return (
<Main>
<Header title={c.title} subtitle={c.subtitle}>
<Button size="sm" variant="outline" iconLeft="download" onClick={exportCsv}>
{t.export}
</Button>
</Header>
<div className="b5a-rise mt-6 grid grid-cols-[minmax(0,1fr)] gap-3 [--b5a-d:60ms] sm:grid-cols-2 lg:grid-cols-[minmax(0,1fr)_12rem_12rem]">
<SearchBox label={t.search} value={q} onChange={setQ} className="sm:col-span-2 lg:col-span-1" />
<Pick label={t.person} value={who} onChange={setWho}>
<option value="all">{t.everyone}</option>
{people.map((x) => (
<option key={x.id} value={x.id}>
{c.who(x.id)}
</option>
))}
</Pick>
<Pick label={t.kind} value={kind} onChange={setKind}>
<option value="all">{t.allKinds}</option>
{KINDS.filter((k) => events.some((e) => e.kind === k)).map((k) => (
<option key={k} value={k}>
{t[k]}
</option>
))}
</Pick>
</div>
<div className="b5a-rise mt-4 overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] [--b5a-d:120ms]">
{/* Tablet and desktop */}
<table className="w-full table-fixed text-left text-[13.5px] max-md:hidden">
<thead className="border-b border-border bg-surface text-[12px] text-muted-foreground">
<tr>
<th scope="col" className="w-12 ps-3">
<span className="sr-only">{t.details.replace('{what}', '')}</span>
</th>
{sortTh('time', t.time, 'w-36')}
{sortTh('actor', t.actor, 'w-44')}
{sortTh('kind', t.action)}
<th scope="col" className="w-52 py-2 pe-4 font-medium max-lg:hidden">{t.place}</th>
</tr>
</thead>
{slice.map((e) => {
const isOpen = open === e.id;
return (
<tbody key={e.id} className="border-b border-border last:border-b-0">
<tr className={cn('transition-colors', isOpen ? 'bg-[color-mix(in_oklab,var(--primary)_4%,var(--card))]' : 'hover:bg-[color-mix(in_oklab,var(--foreground)_2.5%,transparent)]')}>
<td className="ps-3">
<button type="button" aria-expanded={isOpen} aria-controls={`${id}-${e.id}`} aria-label={fill(t.details, { what: `${c.who(e.actor)} ${e.action} ${e.target ?? ''}` })} onClick={() => setOpen(isOpen ? null : e.id)} className={cn('relative grid size-8 place-items-center rounded-md text-muted-foreground transition-colors after:absolute after:-inset-1.5 hover:bg-accent hover:text-foreground', ring)}>
<Icon name="chevron-right" size={15} className={cn('transition-transform duration-300 ease-[var(--ease-kit)]', isOpen && 'rotate-90')} />
</button>
</td>
<td className="py-3 tabular-nums text-muted-foreground">
<time dateTime={e.time} title={stampFull(e.time, locale)}>
{ago(e.time, now, locale)}
</time>
</td>
<td className="py-3">
<span className="flex min-w-0 items-center gap-2">
<Avatar name={c.person(e.actor).name} size="xs" alt="" />
<span className="truncate font-medium text-foreground">{c.who(e.actor)}</span>
</span>
</td>
<td className="py-3 pe-3">
<span className="flex min-w-0 items-center gap-2.5">
<KindDot kind={e.kind} size="sm" className="ring-0" />
<span className="min-w-0 line-clamp-2 text-pretty break-words text-muted-foreground">
{e.action} {e.target && <span className="font-medium text-foreground">{e.target}</span>}
</span>
</span>
</td>
<td className="py-3 pe-4 max-lg:hidden">
<span className="block truncate text-foreground">{e.location}</span>
<span className="block truncate text-[12px] tabular-nums text-subtle-foreground">{e.ip}</span>
</td>
</tr>
<tr id={`${id}-${e.id}`} hidden={!isOpen}>
<td colSpan={5} className="border-t border-border p-0">
{isOpen && (
<div className="b5a-rise">
{detail(e)}
</div>
)}
</td>
</tr>
</tbody>
);
})}
</table>
{/* Phones */}
<ul className="md:hidden">
{slice.map((e) => {
const isOpen = open === e.id;
return (
<li key={e.id} className="border-b border-border last:border-b-0">
<button type="button" aria-expanded={isOpen} aria-controls={`${id}-m-${e.id}`} onClick={() => setOpen(isOpen ? null : e.id)} className={cn('flex w-full items-start gap-3 px-4 py-3.5 text-left', ringIn)}>
<KindDot kind={e.kind} size="sm" className="mt-0.5 ring-0" />
<span className="min-w-0 flex-1">
<Sentence e={e} c={c} className="text-[13.5px]" />
<span className="mt-1 block truncate text-[12px] tabular-nums text-subtle-foreground">
{ago(e.time, now, locale)}
{e.location && ` · ${e.location}`}
</span>
</span>
<Icon name="chevron-down" size={16} className={cn('mt-1 shrink-0 text-muted-foreground transition-transform duration-300', isOpen && 'rotate-180')} />
</button>
<div id={`${id}-m-${e.id}`} hidden={!isOpen} className="border-t border-border">
{isOpen && detail(e)}
</div>
</li>
);
})}
</ul>
{!slice.length && <p className="px-4 py-12 text-center text-[13.5px] text-muted-foreground">{t.none}</p>}
<div className="flex items-center justify-between gap-3 border-t border-border bg-surface px-4 py-2.5 text-[12.5px] text-muted-foreground sm:px-5">
<span className="tabular-nums" aria-live="polite">
{rows.length ? fill(t.showing, { from: p * size + 1, to: Math.min(rows.length, p * size + size), total: rows.length }) : ''}
</span>
<span className="flex items-center gap-1">
<span className="me-2 tabular-nums max-sm:hidden">{fill(t.page, { n: p + 1, total: pages })}</span>
<IconButton icon="chevron-left" size="sm" variant="outline" label={t.prev} disabled={p === 0} onClick={() => setPage(p - 1)} />
<IconButton icon="chevron-right" size="sm" variant="outline" label={t.next} disabled={p >= pages - 1} onClick={() => setPage(p + 1)} />
</span>
</div>
</div>
<Live text={live} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* record-history */
/* ------------------------------------------------------------------ */
function RecordHistory(c: Ctx) {
const { t, locale, now, onAction } = c;
const [record, setRecord] = useState(c.record ?? { title: '', fields: [] });
const [events, setEvents] = useState(() => [...c.events].sort((a, b) => toMs(b.time) - toMs(a.time)));
const [only, setOnly] = useState('all');
const [flash, setFlash] = useState<string>();
const [fresh, setFresh] = useState<string>();
const [showRecord, setShowRecord] = useState(false);
const [live, setLive] = useState('');
const id = useId();
const label = (fid: string) => record.fields.find((f) => f.id === fid)?.label ?? fid;
const shown = events.filter((e) => only === 'all' || e.changes?.some((ch) => ch.field === only));
const restore = async (ch: ActivityChange) => {
const cur = record.fields.find((f) => f.id === ch.field);
if (!cur || ch.from === undefined) return;
const ev: ActivityEvent = { id: `x${Date.now()}`, actor: c.me ?? '', kind: 'update', action: t.restoredAction, time: now, changes: [{ field: ch.field, from: cur.value, to: ch.from }] };
setRecord((r) => ({ ...r, fields: r.fields.map((f) => (f.id === ch.field ? { ...f, value: ch.from ?? '' } : f)) }));
setEvents((l) => [ev, ...l]);
setFresh(ev.id);
setFlash(undefined);
requestAnimationFrame(() => setFlash(ch.field));
setLive(fill(t.restored, { field: cur.label, value: ch.from || t.empty }));
await run(onAction, 'restore', { field: ch.field, value: ch.from });
};
return (
<Main>
<Header title={c.title} subtitle={c.subtitle} />
<div className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[20rem_minmax(0,1fr)] lg:items-start">
<section aria-labelledby={`${id}-r`} className="b5a-rise rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] [--b5a-d:60ms] lg:sticky lg:top-6">
<div className="flex items-center gap-3 border-b border-border px-4 py-3.5 sm:px-5">
<span className="grid size-10 shrink-0 place-items-center rounded-lg bg-[color-mix(in_oklab,var(--primary)_12%,var(--surface-2))] text-[color-mix(in_oklab,var(--primary)_82%,var(--foreground))]">
<Icon name="briefcase" size={18} />
</span>
<div className="min-w-0 flex-1">
{record.type && <p className="text-[12px] text-muted-foreground">{record.type}</p>}
<h2 id={`${id}-r`} className="truncate text-[15px] font-semibold text-foreground">
{record.title}
</h2>
</div>
<button type="button" aria-expanded={showRecord} aria-controls={`${id}-f`} onClick={() => setShowRecord((v) => !v)} className={cn('-me-1 grid size-11 place-items-center rounded-lg text-muted-foreground hover:bg-accent lg:hidden', ring)}>
<Icon name="chevron-down" size={18} className={cn('transition-transform duration-300', showRecord && 'rotate-180')} />
<span className="sr-only">{showRecord ? t.hideRecord : t.showRecord}</span>
</button>
</div>
<dl id={`${id}-f`} className={cn('px-4 py-2 sm:px-5', !showRecord && 'max-lg:hidden')}>
<p className="pb-1 pt-2 text-[12px] font-medium uppercase tracking-[0.06em] text-subtle-foreground">{t.current}</p>
{record.fields.map((f) => (
<div key={f.id} className={cn('-mx-2 flex items-baseline justify-between gap-4 rounded-md border-b border-border px-2 py-2.5 last:border-b-0', flash === f.id && 'b5a-flash')}>
<dt className="text-[13px] text-muted-foreground">{f.label}</dt>
<dd className="text-right text-[13.5px] font-medium text-foreground [overflow-wrap:anywhere]">{f.value || t.empty}</dd>
</div>
))}
</dl>
</section>
<section aria-label={t.history} className="min-w-0">
<div className="b5a-rise flex flex-wrap items-center justify-between gap-3 [--b5a-d:100ms]">
<h2 className="text-[15px] font-semibold text-foreground">{t.history}</h2>
<Pick label={t.fields} value={only} onChange={setOnly} className="w-full sm:w-52">
<option value="all">{t.allFields}</option>
{record.fields.map((f) => (
<option key={f.id} value={f.id}>
{f.label}
</option>
))}
</Pick>
</div>
<ol className="relative mt-4 grid gap-3">
<span aria-hidden className="b5a-line absolute bottom-6 start-[1.15rem] top-6 w-px bg-border max-sm:hidden" />
{shown.map((e, i) => {
const list = (e.changes ?? []).filter((ch) => only === 'all' || ch.field === only);
return (
<li key={e.id} className={cn('relative flex gap-3 sm:gap-4', e.id === fresh ? 'b5a-rise' : 'b5a-rise')} style={{ '--b5a-d': `${e.id === fresh ? 0 : 140 + Math.min(i, 6) * 60}ms` } as React.CSSProperties}>
<span className="relative max-sm:hidden">
<Avatar name={c.person(e.actor).name} size="sm" alt="" className="ring-4 ring-background" />
</span>
<article className={cn('min-w-0 flex-1 rounded-xl border bg-card shadow-[var(--highlight),var(--shadow-sm)] transition-[border-color] hover:border-border-strong', e.id === fresh ? 'border-[color-mix(in_oklab,var(--primary)_40%,var(--border))]' : 'border-border')}>
<header className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-0.5 border-b border-border px-4 py-2.5">
<p className="text-[13.5px] text-muted-foreground">
<span className="font-semibold text-foreground">{c.who(e.actor)}</span> {e.changes?.length ? (e.action || (e.changes.length === 1 ? fill(t.changedOne, { field: label(e.changes[0].field) }) : fill(t.changedN, { n: e.changes.length }))) : e.action} {e.target && <span className="font-medium text-foreground">{e.target}</span>}
</p>
<time dateTime={e.time} title={stampFull(e.time, locale)} className="text-[12.5px] tabular-nums text-subtle-foreground">
{ago(e.time, now, locale)}
</time>
</header>
{list.length > 0 && (
<ul>
{list.map((ch, k) => {
const cur = record.fields.find((f) => f.id === ch.field)?.value;
const canRestore = ch.from !== undefined && ch.from !== cur && e.id !== fresh;
return (
<li key={k} className="grid grid-cols-[minmax(0,1fr)] items-center gap-2 border-b border-border px-4 py-3 last:border-b-0 sm:grid-cols-[8.5rem_minmax(0,1fr)_auto] sm:gap-4 lg:grid-cols-[minmax(0,1fr)] lg:gap-2 xl:grid-cols-[8.5rem_minmax(0,1fr)_auto] xl:gap-4">
<span className="text-[12.5px] font-medium text-muted-foreground">{label(ch.field)}</span>
<span className="flex min-w-0 flex-wrap items-center gap-2 text-[13.5px]">
<span className="rounded bg-[color-mix(in_oklab,var(--destructive)_9%,transparent)] px-1.5 py-0.5 text-muted-foreground line-through decoration-[color-mix(in_oklab,var(--destructive)_55%,transparent)] [overflow-wrap:anywhere]">{ch.from || t.empty}</span>
<Icon name="arrow-right" size={13} className="shrink-0 text-subtle-foreground" />
<span className="rounded bg-[color-mix(in_oklab,var(--success)_12%,transparent)] px-1.5 py-0.5 font-medium text-foreground [overflow-wrap:anywhere]">{ch.to || t.empty}</span>
</span>
{canRestore ? (
<Button size="sm" variant="ghost" iconLeft="rotate-ccw" className="justify-self-start sm:justify-self-end lg:justify-self-start xl:justify-self-end" onClick={() => restore(ch)}>
<span className="sr-only">{fill(t.restore, { value: `${label(ch.field)} ${ch.from || t.empty}` })}</span>
<span aria-hidden>{fill(t.restore, { value: '' }).trim()}</span>
</Button>
) : (
<span className="max-sm:hidden lg:max-xl:hidden" />
)}
</li>
);
})}
</ul>
)}
{e.detail && <p className="px-4 py-3 text-pretty text-[13.5px] text-foreground">{e.detail}</p>}
</article>
</li>
);
})}
</ol>
</section>
</div>
<Live text={live} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* compact-feed */
/* ------------------------------------------------------------------ */
function CompactFeed(c: Ctx) {
const { t, locale, now, onAction, children } = c;
const [items, setItems] = useState(() => [...c.events].sort((a, b) => toMs(b.time) - toMs(a.time)));
const [rest, setRest] = useState(c.more ?? []);
const [tab, setTab] = useState<'all' | 'mine'>('all');
const [busy, setBusy] = useState(false);
const [seenAll, setSeenAll] = useState(false);
const [added, setAdded] = useState<string[]>([]);
const [live, setLive] = useState('');
const step = 4;
const mine = (e: ActivityEvent) => !!c.me && ((e.detail ?? '').includes('@' + c.person(c.me).name.split(' ')[0]) || e.kind === 'invite' || e.kind === 'share');
const shown = items.filter((e) => tab === 'all' || mine(e));
const unread = seenAll ? 0 : items.filter((e) => e.unread).length;
const firstOld = shown.findIndex((e) => !e.unread);
const load = async () => {
setBusy(true);
await run(onAction, 'load-more', { after: items[items.length - 1]?.id }, 800);
const next = rest.slice(0, step);
setItems((l) => [...l, ...next]);
setRest((r) => r.slice(step));
setAdded(next.map((e) => e.id));
setBusy(false);
setLive(fill(t.loaded, { n: next.length }));
};
const panel = (
<aside aria-label={t.activity} className="b5a-rise flex min-w-0 flex-col rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] lg:sticky lg:top-6 lg:max-h-[calc(100dvh-3rem)]">
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-border px-4 py-3">
<h2 className="flex items-center gap-2 text-[14px] font-semibold text-foreground">
{t.activity}
{unread > 0 && <span className="b5a-pop grid h-5 min-w-5 place-items-center rounded-full bg-primary px-1.5 text-[11px] font-semibold tabular-nums text-primary-foreground">{unread}</span>}
</h2>
<IconButton
icon="check-check"
size="sm"
label={t.markSeen}
disabled={!unread}
onClick={() => {
setSeenAll(true);
setLive(t.seen);
void run(onAction, 'seen', {});
}}
/>
</div>
<div className="shrink-0 px-3 pt-3">
<Segmented
label={t.activity}
value={tab}
onChange={setTab}
className="flex w-full"
options={[
{ value: 'all', label: t.all },
{ value: 'mine', label: t.mentions },
]}
/>
</div>
<div className="kit-scrollbar relative min-h-0 flex-1 overflow-y-auto px-2 py-2">
<ul>
{shown.map((e, i) => (
<li key={e.id} className={cn(added.includes(e.id) || i < 8 ? 'b5a-rise' : '')} style={{ '--b5a-d': `${added.includes(e.id) ? added.indexOf(e.id) * 60 : 80 + i * 40}ms` } as React.CSSProperties}>
{i === firstOld && firstOld > 0 && !seenAll && (
<p className="my-1.5 flex items-center gap-2 px-2 text-[11.5px] font-medium text-[color-mix(in_oklab,var(--primary)_82%,var(--foreground))]">
<span className="h-px flex-1 bg-[color-mix(in_oklab,var(--primary)_40%,transparent)]" />
{t.newSince}
</p>
)}
<div className="group/it relative flex gap-2.5 rounded-lg px-2 py-2.5 transition-colors hover:bg-surface">
<span className="relative">
<Avatar name={c.person(e.actor).name} size="sm" alt="" />
<span className={cn('absolute -bottom-1 -end-1 grid size-[18px] place-items-center rounded-full ring-2 ring-card', KIND[e.kind].tone)}>
<Icon name={KIND[e.kind].icon} size={10} strokeWidth={2} />
</span>
</span>
<div className="min-w-0 flex-1">
<p className="text-pretty text-[13px] leading-snug text-muted-foreground">
<span className="font-semibold text-foreground">{c.who(e.actor)}</span> {e.action} {e.target && <span className="font-medium text-foreground">{e.target}</span>}
</p>
{e.detail && <p className="mt-1 line-clamp-2 text-pretty text-[12.5px] text-muted-foreground">„{e.detail}“</p>}
<time dateTime={e.time} title={stampFull(e.time, locale)} className="mt-1 block text-[11.5px] tabular-nums text-subtle-foreground">
{agoShort(e.time, now, locale)}
</time>
</div>
{e.unread && !seenAll && <span aria-hidden className="b5a-pop mt-1.5 size-2 shrink-0 rounded-full bg-primary" />}
</div>
</li>
))}
</ul>
<div className="px-2 pb-2 pt-1">
{rest.length ? (
<Button variant="outline" size="sm" className="w-full" iconLeft={busy ? 'loader' : 'arrow-down'} disabled={busy} onClick={load}>
{busy ? t.loading : t.loadMore}
</Button>
) : (
<p className="py-2 text-center text-[12px] text-subtle-foreground">{t.end}</p>
)}
</div>
</div>
</aside>
);
return (
<Main>
<Header title={c.title} subtitle={c.subtitle} />
<div className={cn('mt-6 grid grid-cols-[minmax(0,1fr)] gap-6', children ? 'lg:grid-cols-[minmax(0,1fr)_21rem] lg:items-start' : 'mx-auto max-w-[24rem]')}>
{children && <div className="b5a-rise min-w-0 max-lg:hidden">{children}</div>}
<div className="mx-auto w-full max-w-[34rem] lg:max-w-none">{panel}</div>
</div>
<Live text={live} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* digest */
/* ------------------------------------------------------------------ */
function Delta({ now, before, t, locale }: { now: number; before: number; t: Texts; locale: Locale }) {
if (!before && !now) return null;
const pct = before ? Math.round(((now - before) / before) * 100) : 100;
const up = pct >= 0;
return (
<span className={cn('inline-flex items-center gap-1 text-[12.5px] font-medium tabular-nums', up ? 'text-success-foreground' : 'text-muted-foreground')}>
<Icon name={up ? 'trending-up' : 'trending-down'} size={13} />
{new Intl.NumberFormat(intl(locale), { signDisplay: 'exceptZero', style: 'percent' }).format(pct / 100)}
<span className="font-normal text-subtle-foreground">{t.vsPrev}</span>
</span>
);
}
function Digest(c: Ctx) {
const { t, locale, now, events } = c;
const [mode, setMode] = useState<'day' | 'week'>('week');
const [offset, setOffset] = useState(0);
useEffect(() => setOffset(0), [mode]);
const len = mode === 'day' ? 1 : 7;
// Periods end with the day of now
const endDay = toMs(dayKey(now)) + DAY - offset * len * DAY;
const startDay = endDay - len * DAY;
const inRange = (a: number, b: number) => events.filter((e) => toMs(e.time) >= a && toMs(e.time) < b);
const cur = inRange(startDay, endDay);
const prev = inRange(startDay - len * DAY, startDay);
const oldest = Math.min(...events.map((e) => toMs(e.time)));
const stats = (l: ActivityEvent[]) => ({
changes: l.filter((e) => e.kind === 'update' || e.kind === 'status' || e.kind === 'delete').length,
comments: l.filter((e) => e.kind === 'comment').length,
created: l.filter((e) => e.kind === 'create' || e.kind === 'upload').length,
people: new Set(l.map((e) => e.actor)).size,
});
const s = stats(cur);
const sp = stats(prev);
const fmtDay = (ms: number) => new Intl.DateTimeFormat(intl(locale), { day: 'numeric', month: 'long', timeZone: 'UTC' }).format(new Date(ms));
const label = mode === 'day' ? dayLabel(isoOf(startDay), now, locale, t) : `${fmtDay(startDay)} ${locale === 'de' ? 'bis' : 'to'} ${fmtDay(endDay - DAY)}`;
const data =
mode === 'week'
? Array.from({ length: 7 }, (_, i) => {
const a = startDay + i * DAY;
return { day: isoOf(a).slice(0, 10), n: inRange(a, a + DAY).length };
})
: Array.from({ length: 12 }, (_, i) => {
const a = startDay + (7 + i) * 60 * MIN;
return { day: new Intl.DateTimeFormat(intl(locale), { hour: 'numeric', timeZone: 'UTC' }).format(new Date(a)), n: inRange(a, a + 60 * MIN).length };
});
const top = Object.entries(cur.reduce<Record<string, number>>((m, e) => ((m[e.actor] = (m[e.actor] ?? 0) + 1), m), {}))
.sort((a, b) => b[1] - a[1])
.slice(0, 5);
const hot = Object.entries(cur.filter((e) => e.target).reduce<Record<string, number>>((m, e) => ((m[e.target!] = (m[e.target!] ?? 0) + 1), m), {}))
.sort((a, b) => b[1] - a[1])
.slice(0, 4);
const max = Math.max(1, ...top.map((x) => x[1]));
const tiles: { label: string; icon: IconName; n: number; p: number }[] = [
{ label: t.changes, icon: 'edit', n: s.changes, p: sp.changes },
{ label: t.comments, icon: 'message-circle', n: s.comments, p: sp.comments },
{ label: t.created, icon: 'plus-circle', n: s.created, p: sp.created },
{ label: t.activePeople, icon: 'users', n: s.people, p: sp.people },
];
const nf = new Intl.NumberFormat(intl(locale));
return (
<Main>
<Header title={c.title} subtitle={c.subtitle}>
<Segmented
label={t.period}
value={mode}
onChange={setMode}
options={[
{ value: 'day', label: t.daily },
{ value: 'week', label: t.weekly },
]}
/>
</Header>
<div className="b5a-rise mt-5 flex items-center gap-2 [--b5a-d:60ms]">
<IconButton icon="chevron-left" size="sm" variant="outline" label={t.prevPeriod} disabled={startDay <= oldest} onClick={() => setOffset((o) => o + 1)} />
<IconButton icon="chevron-right" size="sm" variant="outline" label={t.nextPeriod} disabled={offset === 0} onClick={() => setOffset((o) => Math.max(0, o - 1))} />
<p key={label} className="b5a-rise ms-1 text-[15px] font-semibold text-foreground" aria-live="polite">
{label}
</p>
</div>
<ul className="mt-4 grid grid-cols-2 gap-px overflow-hidden rounded-xl border border-border bg-border shadow-[var(--highlight),var(--shadow-sm)] lg:grid-cols-4">
{tiles.map((x, i) => (
<li key={x.label} className="bg-card p-4 sm:p-5">
<div className="b5a-rise" style={{ '--b5a-d': `${100 + i * 60}ms` } as React.CSSProperties}>
<p className="flex items-center gap-2 text-[13px] text-muted-foreground">
<Icon name={x.icon} size={15} />
{x.label}
</p>
<p key={`${label}-${x.n}`} className="b5a-pop mt-2 text-[clamp(1.6rem,1.3rem+1vw,2.1rem)] font-semibold leading-none tracking-[-0.03em] text-foreground tabular-nums">
{nf.format(x.n)}
</p>
<p className="mt-2">
<Delta now={x.n} before={x.p} t={t} locale={locale} />
</p>
</div>
</li>
))}
</ul>
<div className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
<section className="b5a-rise rounded-xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] [--b5a-d:200ms] sm:p-5">
<h2 className="text-[14px] font-semibold text-foreground">{mode === 'week' ? t.perDay : t.perHour}</h2>
<div className="mt-3">
{cur.length ? (
<BarChart key={`${mode}-${offset}`} data={data} xKey="day" series={[{ key: 'n', label: t.activity }]} height={220} label={mode === 'week' ? t.perDay : t.perHour} xFormat={mode === 'week' ? { weekday: 'short' } : undefined} valueLabels />
) : (
<p className="py-16 text-center text-[13.5px] text-muted-foreground">{t.noData}</p>
)}
</div>
</section>
<div className="grid content-start gap-6">
<section className="b5a-rise rounded-xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] [--b5a-d:260ms] sm:p-5">
<h2 className="text-[14px] font-semibold text-foreground">{t.top}</h2>
{top.length ? (
<ol className="mt-3 grid gap-3">
{top.map(([pid, n], i) => (
<li key={`${label}-${pid}`} className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3">
<Avatar name={c.person(pid).name} size="sm" alt="" />
<span className="min-w-0">
<span className="block truncate text-[13.5px] font-medium text-foreground">{c.who(pid)}</span>
<span className="mt-1 block h-1.5 overflow-hidden rounded-full bg-track">
<span className="block h-full origin-left rounded-full bg-primary motion-safe:animate-[b5a-grow_.9s_var(--ease-kit)_both]" style={{ width: `${Math.round((n / max) * 100)}%`, animationDelay: `${i * 70}ms` }} />
</span>
</span>
<span className="text-[13px] font-semibold tabular-nums text-foreground">{n}</span>
</li>
))}
</ol>
) : (
<p className="mt-3 text-[13px] text-muted-foreground">{t.noData}</p>
)}
<style>{'@keyframes b5a-grow{from{transform:scaleX(0)}}'}</style>
</section>
<section className="b5a-rise rounded-xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] [--b5a-d:320ms] sm:p-5">
<h2 className="text-[14px] font-semibold text-foreground">{t.hot}</h2>
{hot.length ? (
<ul className="mt-2">
{hot.map(([name, n]) => (
<li key={name} className="flex items-center justify-between gap-3 border-b border-border py-2.5 last:border-b-0">
<span className="truncate text-[13.5px] text-foreground">{name}</span>
<span className="shrink-0 text-[12.5px] tabular-nums text-muted-foreground">{fill(t.nEvents, { n })}</span>
</li>
))}
</ul>
) : (
<p className="mt-3 text-[13px] text-muted-foreground">{t.noData}</p>
)}
</section>
</div>
</div>
</Main>
);
}
/* ------------------------------------------------------------------ */
/* ActivityLog */
/* ------------------------------------------------------------------ */
/**
* Activity screens for apps: a timeline by day with filters, an audit table
* with before and after, the history of one record field by field with
* restore, a narrow feed for side columns and a summary per day or week.
* <ActivityLog variant="timeline" title="Activity" people={[…]} events={[…]} />
*/
export function ActivityLog({ variant = 'timeline', sidebar, labels, className, now = '2026-10-23T14:30', ...props }: ActivityLogProps) {
const t = useTexts(TEXTS, labels);
const locale = useLocale();
const person = (id: string): ActivityPerson => props.people.find((p) => p.id === id) ?? { id, name: id };
const who = (id: string) => (id === props.me ? t.you : person(id).name);
const c: Ctx = { ...props, t, locale, now, person, who };
const body = (() => {
switch (variant) {
case 'audit-table':
return <AuditTable {...c} />;
case 'record-history':
return <RecordHistory {...c} />;
case 'compact-feed':
return <CompactFeed {...c} />;
case 'digest':
return <Digest {...c} />;
default:
return <Timeline {...c} />;
}
})();
if (!sidebar)
return (
<div className={cn('min-h-dvh bg-background', className)}>
<Style />
{body}
</div>
);
return (
<Sidebar mobile="drawer" {...sidebar} className={className}>
<Style />
{body}
</Sidebar>
);
}Häufige Fragen
Wie installiere ich Aktivitätsprotokoll?
Mit aiw add @ai-website/activity-log. Der Befehl legt die Datei components/ui/activity-log.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind avatar, bar-chart, button, icon, icon-button, motion, reveal und sidebar. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Aktivitätsprotokoll?
5 Varianten, Zeitleiste, Prüftabelle, Verlauf eines Datensatzes, Seitenfeed und Zusammenfassung. Du wählst sie mit der Prop variant, zum Beispiel variant="audit-table".
Kann ich die Texte von Aktivitätsprotokoll übersetzen?
Ja. Aktivitätsprotokoll bringt 84 eigene Texte auf Deutsch und Englisch mit, etwa „Aktivitäten durchsuchen“, „Person“ und „Alle Personen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Aktivitätsprotokoll einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Aktivitätsprotokoll mit aiw_components und installieren sie mit aiw_add @ai-website/activity-log. Nenn im Prompt die Variante, etwa variant="timeline", dann schreibt der Assistent deine Inhalte direkt in die Props.