spb/tools-spboucher-ai
Public
1import Link from "next/link";2import { sql } from "@/db";3import { requireUserPage } from "@/lib/auth/guard";4import { fmtDate } from "@/lib/utils";5import { USE_STATES } from "@/lib/content/schema";6import { ProjectsPanel } from "@/components/admin/projects-panel";7export default async function Owner() {8 const s = await requireUserPage(); if (s.role !== "owner") return <p>Owner only.</p>;9 const rows = await sql`SELECT t.id, t.slug, t.name, t.summary, t.visibility, t.editorial_status AS "editorialStatus", o.use_state AS "useState", o.rating, o.priority, o.cluster_compat AS "clusterCompat", o.subscription_status AS "subscriptionStatus", o.review_at AS "reviewAt", o.notes, o.deployment_urls AS "deploymentUrls", (SELECT coalesce(array_agg(p.name ORDER BY p.name), '{}') FROM project_tools pt JOIN projects p ON p.id=pt.project_id WHERE pt.tool_id=t.id) AS projects FROM owner_notes o JOIN tools t ON t.id=o.tool_id ORDER BY o.priority='high' DESC, o.priority='medium' DESC, t.name`;10 const projects = await sql`SELECT p.slug, p.name, p.description, (SELECT count(*)::int FROM project_tools pt WHERE pt.project_id=p.id) AS tools FROM projects p ORDER BY p.name`;11 const due = rows.filter((r) => r.reviewAt && new Date(r.reviewAt) <= new Date());12 return (13 <div className="space-y-8">14 <div><h1 className="display h1">Owner dashboard <span className="chip chip-accent align-middle">private</span></h1><p className="mt-1 text-sm text-fg-2">Your personal inventory. Nothing on this page is ever served by public routes or the public database role.</p></div>15 {due.length > 0 && <section className="card border-warn/40 p-4"><h2 className="font-semibold">Review reminders due ({due.length})</h2><ul className="mt-2 flex flex-wrap gap-2 text-sm">{due.map((r) => <li key={r.id}><Link href={`/admin/tools/${r.id}`} className="chip hover:text-accent">{r.name} · {fmtDate(r.reviewAt)}</Link></li>)}</ul></section>}16 <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-5">{USE_STATES.map((st) => { const items = rows.filter((r) => r.useState === st); return (17 <section key={st} className="card p-4"><h2 className="flex items-center justify-between font-semibold capitalize">{st}<span className="chip">{items.length}</span></h2>18 <ul className="mt-3 space-y-2">{items.map((r) => <li key={r.id} className="rounded-xl border border-line p-2.5 text-sm"><div className="flex items-center gap-2"><Link href={`/admin/tools/${r.id}`} className="font-medium hover:text-accent">{r.name}</Link>{r.visibility === "private" && <span className="chip">private</span>}{r.rating && <span className="ml-auto text-xs text-fg-3">{"★".repeat(r.rating)}</span>}</div><p className="mt-0.5 text-xs text-fg-3">{r.priority} priority · cluster {r.clusterCompat}{r.subscriptionStatus ? ` · ${r.subscriptionStatus}` : ""}{r.projects.length ? ` · ${r.projects.join(", ")}` : ""}</p>{r.notes && <p className="mt-1 line-clamp-2 text-xs text-fg-2">{r.notes}</p>}{(r.deploymentUrls as any[])?.length > 0 && <p className="mt-1 text-xs">{(r.deploymentUrls as any[]).map((d) => <a key={d.url} href={d.url} className="mr-2 text-accent hover:underline" target="_blank" rel="noopener noreferrer">{d.label}</a>)}</p>}</li>)}{!items.length && <li className="text-xs text-fg-3">—</li>}</ul></section>); })}</div>19 <section><h2 className="font-semibold">Projects</h2><ProjectsPanel projects={JSON.parse(JSON.stringify(projects))} /></section>20 </div>21 );22}23