page.tsx125 lines · main
| 1 | import Link from 'next/link'; |
| 2 | |
| 3 | import { FoldersIcon } from '@/components/ui/folders'; |
| 4 | import { LayoutGridIcon } from '@/components/ui/layout-grid'; |
| 5 | |
| 6 | import { apiJson } from '@/lib/api'; |
| 7 | import { toValidDate } from '@/lib/utils'; |
| 8 | |
| 9 | import { EmptyState } from '../_components/empty-state'; |
| 10 | import { Section } from '../_components/section'; |
| 11 | import { StatCard } from '../_components/stat-card'; |
| 12 | |
| 13 | import { SetMineToProButton } from './project-actions'; |
| 14 | |
| 15 | interface AdminProject { |
| 16 | id: string; |
| 17 | slug: string; |
| 18 | name: string; |
| 19 | ownerId: string; |
| 20 | tier: string; |
| 21 | createdAt: string; |
| 22 | } |
| 23 | |
| 24 | export const metadata = { title: 'projects · admin' }; |
| 25 | export const dynamic = 'force-dynamic'; |
| 26 | |
| 27 | export 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 | } |