projects-list.tsx153 lines · main
1'use client';
2
3import Link from 'next/link';
4import { useMemo, useState } from 'react';
5
6import { RowDeleteProjectButton } from './row-delete-project-button';
7
8interface Project {
9 id: string;
10 slug: string;
11 name: string;
12 region: string;
13 tier: 'free' | 'pro' | 'team';
14 createdAt: string;
15 orgName: string | null;
16 orgPersonal: boolean | null;
17}
18
19/**
20 * Client-side filterable projects list. Server fetches every project the
21 * user can see; this component lets them narrow by name/slug or by org.
22 * Kept client-side because the dataset is already capped at "everything
23 * the user can access" — no point paginating server-side.
24 */
25export function ProjectsList({
26 projects,
27 apiOrigin,
28}: {
29 projects: Project[];
30 apiOrigin: string;
31}) {
32 const [q, setQ] = useState('');
33 const [orgFilter, setOrgFilter] = useState<string>('');
34
35 // Phase 7 of BACKEND_FORK_BRIEF.md — project card opens the per-project
36 // studio at studio.briven.tech when NEXT_PUBLIC_BRIVEN_STUDIO_ORIGIN is
37 // set. Falls back to the internal /dashboard/projects/[id] detail page
38 // when not configured, so the dashboard still works in environments
39 // where the external studio hasn't been deployed yet.
40 const studioOrigin = process.env.NEXT_PUBLIC_BRIVEN_STUDIO_ORIGIN;
41 const projectHref = (id: string): string =>
42 studioOrigin ? `${studioOrigin}/project/${id}` : `/dashboard/projects/${id}`;
43
44 const orgs = useMemo(() => {
45 const set = new Map<string, { name: string; personal: boolean }>();
46 for (const p of projects) {
47 if (p.orgName && !set.has(p.orgName)) {
48 set.set(p.orgName, { name: p.orgName, personal: p.orgPersonal ?? false });
49 }
50 }
51 return Array.from(set.values()).sort((a, b) => {
52 if (a.personal !== b.personal) return a.personal ? -1 : 1;
53 return a.name.localeCompare(b.name);
54 });
55 }, [projects]);
56
57 const filtered = useMemo(() => {
58 const needle = q.trim().toLowerCase();
59 return projects.filter((p) => {
60 if (orgFilter && p.orgName !== orgFilter) return false;
61 if (!needle) return true;
62 return (
63 p.name.toLowerCase().includes(needle)
64 || p.slug.toLowerCase().includes(needle)
65 || p.id.toLowerCase().includes(needle)
66 );
67 });
68 }, [projects, q, orgFilter]);
69
70 return (
71 <div className="flex flex-col gap-3">
72 {projects.length > 5 || orgs.length > 1 ? (
73 <div className="flex flex-wrap items-center gap-2">
74 <input
75 type="text"
76 value={q}
77 onChange={(e) => setQ(e.target.value)}
78 placeholder="filter by name / slug / id"
79 className="flex-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 font-mono text-xs outline-none focus:border-[var(--color-primary)]"
80 />
81 {orgs.length > 1 ? (
82 <select
83 value={orgFilter}
84 onChange={(e) => setOrgFilter(e.target.value)}
85 className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 font-mono text-xs outline-none focus:border-[var(--color-primary)]"
86 >
87 <option value="">all orgs</option>
88 {orgs.map((o) => (
89 <option key={o.name} value={o.name}>
90 {o.name} {o.personal ? '· personal' : '· team'}
91 </option>
92 ))}
93 </select>
94 ) : null}
95 {q || orgFilter ? (
96 <span className="font-mono text-[10px] text-[var(--color-text-subtle)]">
97 {filtered.length} of {projects.length}
98 </span>
99 ) : null}
100 </div>
101 ) : null}
102
103 {filtered.length === 0 ? (
104 <p className="rounded-md border border-dashed border-[var(--color-border)] p-6 text-center font-mono text-xs text-[var(--color-text-muted)]">
105 no projects match that filter.
106 </p>
107 ) : (
108 // Responsive card grid: 1 col on phones, 2 on tablets, 3 on
109 // regular screens, 4 on large screens.
110 <ul className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
111 {filtered.map((p) => (
112 <li
113 key={p.id}
114 className="group relative flex flex-col rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] transition hover:border-[var(--color-border)]"
115 >
116 <Link
117 href={projectHref(p.id)}
118 className="flex flex-1 flex-col gap-1.5 p-4"
119 {...(studioOrigin
120 ? { rel: 'noopener', target: '_self' }
121 : {})}
122 >
123 <p className="pr-8 font-mono text-sm">{p.name}</p>
124 <p className="font-mono text-xs text-[var(--color-text-subtle)]">
125 {p.slug} · {p.region} · {p.tier}
126 {p.orgName ? (
127 <span>
128 {' · '}
129 <span className="text-[var(--color-text-muted)]">
130 {p.orgName}
131 {p.orgPersonal ? '' : ' (team)'}
132 </span>
133 </span>
134 ) : null}
135 </p>
136 <span className="mt-auto pt-2 font-mono text-xs text-[var(--color-text-subtle)]">
137 {new Date(p.createdAt).toISOString().slice(0, 10)}
138 </span>
139 </Link>
140 <div className="absolute right-2 top-2">
141 <RowDeleteProjectButton
142 projectId={p.id}
143 projectName={p.name}
144 apiOrigin={apiOrigin}
145 />
146 </div>
147 </li>
148 ))}
149 </ul>
150 )}
151 </div>
152 );
153}