page.tsx167 lines · main
1import { DatabaseIcon } from '@/components/ui/database';
2import { FoldersIcon } from '@/components/ui/folders';
3import { LayoutGridIcon } from '@/components/ui/layout-grid';
4import { ShieldCheckIcon } from '@/components/ui/shield-check';
5import { TriangleAlertIcon } from '@/components/ui/triangle-alert';
6
7import { apiJson } from '@/lib/api';
8import { toValidDate } from '@/lib/utils';
9
10import { ProjectActions } from '../project-actions';
11
12import { DatabaseControls } from './database-controls';
13
14import { EmptyState } from '../../_components/empty-state';
15import { Section } from '../../_components/section';
16
17interface ProjectDetail {
18 project: {
19 id: string;
20 slug: string;
21 name: string;
22 ownerId: string;
23 tier: 'free' | 'pro' | 'team';
24 suspendedAt: string | null;
25 suspendReason: string | null;
26 deletedAt: string | null;
27 createdAt: string;
28 };
29}
30
31export const dynamic = 'force-dynamic';
32export const metadata = { title: 'project · admin' };
33
34/** Honest placeholder — anything null/empty renders "—", never a fake value. */
35function dash(v: string | null | undefined): string {
36 return v && v.length > 0 ? v : '—';
37}
38
39/** A date-ish value as a local date string, or "—" when missing/unparseable. */
40function dashDate(v: string | null | undefined): string {
41 const d = toValidDate(v);
42 return d ? d.toLocaleDateString() : '—';
43}
44
45export default async function AdminProjectDetailPage({
46 params,
47}: {
48 params: Promise<{ id: string }>;
49}) {
50 const { id } = await params;
51 const data = await apiJson<ProjectDetail>(`/v1/admin/projects/${id}`).catch(() => null);
52
53 if (data === null) {
54 return (
55 <div className="flex flex-col gap-10">
56 <header className="flex flex-col gap-2">
57 <div className="flex items-center gap-2">
58 <span className="text-[var(--color-primary)]">
59 <FoldersIcon size={20} />
60 </span>
61 <h1 className="font-mono text-xl tracking-tight">project</h1>
62 </div>
63 <a
64 href="/admin/projects"
65 className="font-mono text-[10px] uppercase tracking-wider text-[var(--color-text-subtle)] transition-colors hover:text-[var(--color-text-link)]"
66 >
67 ← all projects
68 </a>
69 </header>
70 <EmptyState
71 icon={<TriangleAlertIcon size={24} />}
72 title="project not found"
73 message="either the api didn't answer or no project exists with this id — head back and pick one from the list."
74 />
75 </div>
76 );
77 }
78
79 const { project } = data;
80
81 return (
82 <div className="flex flex-col gap-10">
83 {/* ── header ───────────────────────────────────────────────────── */}
84 <header className="flex flex-col gap-3">
85 <a
86 href="/admin/projects"
87 className="font-mono text-[10px] uppercase tracking-wider text-[var(--color-text-subtle)] transition-colors hover:text-[var(--color-text-link)]"
88 >
89 ← all projects
90 </a>
91 <div className="flex flex-wrap items-center gap-3">
92 <span className="text-[var(--color-primary)]">
93 <FoldersIcon size={20} />
94 </span>
95 <h1 className="font-mono text-xl tracking-tight">{project.name}</h1>
96 <span className="rounded-full bg-[var(--color-surface-raised)] px-2 py-0.5 font-mono text-[10px] uppercase tracking-wider text-[var(--color-text-muted)]">
97 {project.tier}
98 </span>
99 {project.suspendedAt ? (
100 <span className="rounded-full bg-red-400/20 px-2 py-0.5 font-mono text-[10px] uppercase tracking-wider text-red-400">
101 suspended
102 </span>
103 ) : (
104 <span className="flex items-center gap-1 rounded-full px-2 py-0.5 font-mono text-[10px] uppercase tracking-wider text-[var(--color-success)]">
105 <ShieldCheckIcon size={12} />
106 active
107 </span>
108 )}
109 {project.deletedAt ? (
110 <span className="rounded-full bg-red-400/20 px-2 py-0.5 font-mono text-[10px] uppercase tracking-wider text-red-400">
111 deleted
112 </span>
113 ) : null}
114 </div>
115 <p className="font-mono text-sm text-[var(--color-text-muted)]">
116 project detail — overview and plan-tier control.
117 </p>
118 <p className="font-mono text-xs text-[var(--color-text-subtle)]">{project.id}</p>
119 <div className="pt-1">
120 <ProjectActions
121 project={{ id: project.id, tier: project.tier, suspendedAt: project.suspendedAt }}
122 apiOrigin={process.env.NEXT_PUBLIC_BRIVEN_API_ORIGIN ?? ''}
123 />
124 </div>
125 </header>
126
127 {/* ── overview ─────────────────────────────────────────────────── */}
128 <Section title="overview" icon={<LayoutGridIcon size={16} />}>
129 <div className="rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6">
130 <dl className="grid grid-cols-1 gap-x-8 gap-y-4 font-mono text-sm sm:grid-cols-2">
131 <Field label="name" value={dash(project.name)} />
132 <Field label="slug" value={dash(project.slug)} />
133 <Field label="id" value={dash(project.id)} />
134 <Field label="owner (org)" value={dash(project.ownerId)} />
135 <Field label="plan tier" value={dash(project.tier)} />
136 <Field
137 label="status"
138 value={project.suspendedAt ? 'suspended' : project.deletedAt ? 'deleted' : 'active'}
139 />
140 <Field label="suspend reason" value={dash(project.suspendReason)} />
141 <Field label="created" value={dashDate(project.createdAt)} />
142 </dl>
143 </div>
144 </Section>
145
146 {/* ── database ─────────────────────────────────────────────────── */}
147 <Section title="database" icon={<DatabaseIcon size={16} />}>
148 <DatabaseControls
149 project={{ id: project.id, name: project.name, slug: project.slug }}
150 apiOrigin={process.env.NEXT_PUBLIC_BRIVEN_API_ORIGIN ?? ''}
151 />
152 </Section>
153 </div>
154 );
155}
156
157/** One overview definition-list field: uppercase mono label + value. */
158function Field({ label, value }: { label: string; value: string }) {
159 return (
160 <div className="flex flex-col gap-1">
161 <dt className="text-[10px] uppercase tracking-wider text-[var(--color-text-subtle)]">
162 {label}
163 </dt>
164 <dd className="break-all text-[var(--color-text)]">{value}</dd>
165 </div>
166 );
167}