page.tsx128 lines · main
1import Link from 'next/link';
2
3import { apiJson } from '../../../../../../lib/api';
4
5interface Activity {
6 id: string;
7 action: string;
8 actorId: string | null;
9 ipHash: string | null;
10 metadata: Record<string, unknown> | null;
11 createdAt: string;
12}
13
14export const dynamic = 'force-dynamic';
15
16const FILTERS = [
17 { value: '', label: 'all' },
18 { value: 'studio.', label: 'studio' },
19 { value: 'deploy', label: 'deploys' },
20 { value: 'project.', label: 'project' },
21 { value: 'key.', label: 'api keys' },
22 { value: 'env.', label: 'env vars' },
23 { value: 'invitation.', label: 'invitations' },
24] as const;
25
26export default async function ActivityPage({
27 params,
28 searchParams,
29}: {
30 params: Promise<{ id: string }>;
31 searchParams: Promise<{ prefix?: string }>;
32}) {
33 const { id } = await params;
34 const { prefix } = await searchParams;
35 const qs = prefix ? `?prefix=${encodeURIComponent(prefix)}` : '';
36 const { activity } = await apiJson<{ activity: Activity[] }>(
37 `/v1/projects/${id}/activity${qs}`,
38 );
39
40 const filterBar = (
41 <nav className="flex flex-wrap gap-1">
42 {FILTERS.map((f) => {
43 const active = (prefix ?? '') === f.value;
44 const href = f.value === ''
45 ? `/dashboard/projects/${id}/activity`
46 : `/dashboard/projects/${id}/activity?prefix=${encodeURIComponent(f.value)}`;
47 return (
48 <Link
49 key={f.value}
50 href={href}
51 className={`rounded-md border px-2 py-0.5 font-mono text-[10px] ${
52 active
53 ? 'border-[var(--color-primary)] text-[var(--color-primary)]'
54 : 'border-[var(--color-border)] text-[var(--color-text-muted)] hover:text-[var(--color-text)]'
55 }`}
56 >
57 {f.label}
58 </Link>
59 );
60 })}
61 </nav>
62 );
63
64 if (activity.length === 0) {
65 return (
66 <div className="flex flex-col gap-4">
67 <header>
68 <h2 className="font-mono text-sm text-[var(--color-text)]">activity</h2>
69 <p className="mt-1 font-mono text-xs text-[var(--color-text-muted)]">
70 every platform-level change: project / key / member / env / deployment / studio.
71 </p>
72 </header>
73 {filterBar}
74 <p className="rounded-md border border-dashed border-[var(--color-border)] p-10 text-center font-mono text-sm text-[var(--color-text-muted)]">
75 {prefix
76 ? `no ${prefix.replace(/\.$/, '')} activity yet.`
77 : 'no activity yet.'}
78 </p>
79 </div>
80 );
81 }
82
83 return (
84 <div className="flex flex-col gap-6">
85 <header>
86 <h2 className="font-mono text-sm text-[var(--color-text)]">activity</h2>
87 <p className="mt-1 font-mono text-xs text-[var(--color-text-muted)]">
88 every platform-level change: project / key / member / env / deployment / studio.
89 </p>
90 </header>
91 {filterBar}
92
93 <ul className="flex flex-col">
94 {activity.map((a) => (
95 <li
96 key={a.id}
97 className="flex items-start justify-between gap-4 border-b border-[var(--color-border-subtle)] py-3 last:border-b-0"
98 >
99 <div className="flex-1">
100 <p className="font-mono text-sm">
101 <span className="text-[var(--color-primary)]">{a.action}</span>
102 {a.actorId ? (
103 <span className="ml-2 text-[var(--color-text-muted)]">
104 by {a.actorId.slice(0, 12)}…
105 </span>
106 ) : null}
107 </p>
108 {a.metadata && Object.keys(a.metadata).length > 0 ? (
109 <p className="mt-0.5 font-mono text-xs text-[var(--color-text-subtle)]">
110 {formatMeta(a.metadata)}
111 </p>
112 ) : null}
113 </div>
114 <time className="font-mono text-xs text-[var(--color-text-subtle)]">
115 {new Date(a.createdAt).toISOString().replace('T', ' ').slice(0, 19)}
116 </time>
117 </li>
118 ))}
119 </ul>
120 </div>
121 );
122}
123
124function formatMeta(meta: Record<string, unknown>): string {
125 return Object.entries(meta)
126 .map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`)
127 .join(' · ');
128}