page.tsx125 lines · main
1import Link from 'next/link';
2
3import { FoldersIcon } from '@/components/ui/folders';
4import { LayoutGridIcon } from '@/components/ui/layout-grid';
5
6import { apiJson } from '@/lib/api';
7import { toValidDate } from '@/lib/utils';
8
9import { EmptyState } from '../_components/empty-state';
10import { Section } from '../_components/section';
11import { StatCard } from '../_components/stat-card';
12
13import { SetMineToProButton } from './project-actions';
14
15interface AdminProject {
16 id: string;
17 slug: string;
18 name: string;
19 ownerId: string;
20 tier: string;
21 createdAt: string;
22}
23
24export const metadata = { title: 'projects · admin' };
25export const dynamic = 'force-dynamic';
26
27export default async function AdminProjectsPage() {
28 const { projects } = await apiJson<{ projects: AdminProject[] }>('/v1/admin/projects').catch(
29 () => ({ projects: [] as AdminProject[] }),
30 );
31
32 // Real per-tier counts derived from the fetched list — nothing invented.
33 const tierCounts = new Map<string, number>();
34 for (const p of projects) {
35 tierCounts.set(p.tier, (tierCounts.get(p.tier) ?? 0) + 1);
36 }
37 // Stable, sensible order: known tiers first, anything unexpected after.
38 const tierOrder = ['free', 'pro', 'team'];
39 const tiers = [...tierCounts.keys()].sort(
40 (a, b) =>
41 (tierOrder.includes(a) ? tierOrder.indexOf(a) : tierOrder.length) -
42 (tierOrder.includes(b) ? tierOrder.indexOf(b) : tierOrder.length) || a.localeCompare(b),
43 );
44
45 return (
46 <div className="flex flex-col gap-10">
47 <header className="flex flex-col gap-3">
48 <div className="flex flex-wrap items-start justify-between gap-3">
49 <div className="flex items-center gap-2">
50 <span className="text-[var(--color-primary)]">
51 <FoldersIcon size={20} />
52 </span>
53 <h1 className="font-mono text-xl tracking-tight">projects</h1>
54 </div>
55 <SetMineToProButton apiOrigin={process.env.NEXT_PUBLIC_BRIVEN_API_ORIGIN ?? ''} />
56 </div>
57 <p className="max-w-prose font-mono text-sm text-[var(--color-text-muted)]">
58 every project on the platform, with its plan tier and owner. counts come straight from
59 the live list — no estimates.
60 </p>
61 </header>
62
63 {/* ── the numbers ──────────────────────────────────────────────── */}
64 <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-4">
65 <StatCard
66 label="total projects"
67 value={projects.length}
68 icon={<FoldersIcon size={14} />}
69 tone="primary"
70 hint="non-deleted totals"
71 />
72 {tiers.map((tier) => (
73 <StatCard
74 key={tier}
75 label={`${tier} tier`}
76 value={tierCounts.get(tier) ?? 0}
77 icon={<LayoutGridIcon size={14} />}
78 hint={`projects on the ${tier} plan`}
79 />
80 ))}
81 </div>
82
83 {/* ── the list ─────────────────────────────────────────────────── */}
84 <Section
85 title={`all projects · ${projects.length.toLocaleString()}`}
86 icon={<FoldersIcon size={16} />}
87 >
88 {projects.length === 0 ? (
89 <EmptyState
90 icon={<FoldersIcon size={24} />}
91 title="no projects to show"
92 message="either no projects exist yet, or the api didn't answer — refresh to retry."
93 />
94 ) : (
95 <ul className="flex flex-col divide-y divide-[var(--color-border-subtle)] rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)]">
96 {projects.map((p) => (
97 <li
98 key={p.id}
99 className="flex flex-wrap items-center justify-between gap-4 px-6 py-4 transition-colors hover:bg-[var(--color-surface-raised)]"
100 >
101 <div className="flex flex-col gap-1">
102 <p className="flex flex-wrap items-center gap-2 font-mono text-sm">
103 <Link
104 href={`/admin/projects/${p.id}`}
105 className="hover:text-[var(--color-primary)]"
106 >
107 {p.name}
108 </Link>
109 <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)]">
110 {p.tier}
111 </span>
112 </p>
113 <p className="font-mono text-xs text-[var(--color-text-subtle)]">
114 {p.id} · {p.slug} · owner {p.ownerId} · created{' '}
115 {toValidDate(p.createdAt)?.toISOString().slice(0, 10) ?? '—'}
116 </p>
117 </div>
118 </li>
119 ))}
120 </ul>
121 )}
122 </Section>
123 </div>
124 );
125}