Skip to content
File

Blob: src/client/islands/repositories.tsx

typescript403 lines
1/// <reference lib="dom" />
2 
3import { useMemo, useState } from "react";
4import { FolderGit2, Globe, Lock, Plus } from "lucide-react";
5 
6import { hydrateIsland } from "@/client/hydrate";
7import { Button, Card, EmptyState, ErrorBanner, Input } from "@/client/components/ui";
8 
9// SSR-side repository summary mirrored to keep the island bundle decoupled
10// from the page-side `AccountRepository` shape. Browser islands intentionally
11// re-declare wire types so SSR rename ripples don't reach the bundle.
12export type RepositoriesIslandSummary = {
13 id: string;
14 slug: string;
15 namespaceSlug: string;
16 visibility: "public" | "private";
17 updatedAt: number;
18};
19 
20export type RepositoriesIslandProps = {
21 primaryNamespaceSlug?: string;
22 repositories: RepositoriesIslandSummary[];
23};
24 
25// Cross-island event used by the tokens island (and any future repo-aware
26// island) to refresh its cached repo list when the user creates or toggles
27// a repo. Exported as a constant so consumers don't typo the name.
28export const REPOSITORIES_CHANGED_EVENT = "goc:repositories-changed";
29 
30export type RepositoriesChangedDetail = {
31 repositories: RepositoriesIslandSummary[];
32};
33 
34function formatRelativeTime(epochMs: number): string {
35 const delta = Date.now() - epochMs;
36 if (delta < 60_000) return "just now";
37 const minutes = Math.floor(delta / 60_000);
38 if (minutes < 60) return `${minutes}m ago`;
39 const hours = Math.floor(minutes / 60);
40 if (hours < 24) return `${hours}h ago`;
41 const days = Math.floor(hours / 24);
42 if (days < 30) return `${days}d ago`;
43 return new Date(epochMs).toISOString().slice(0, 10);
44}
45 
46type CreateResult =
47 | {
48 ok: true;
49 id: string;
50 namespaceSlug: string;
51 slug: string;
52 visibility: "public" | "private";
53 updatedAt: number;
54 }
55 | {
56 ok: false;
57 reason:
58 | "invalid-slug"
59 | "invalid-visibility"
60 | "namespace-not-found"
61 | "not-member"
62 | "slug-taken";
63 };
64 
65type ToggleResult =
66 | { ok: true; id: string; visibility: "public" | "private"; previous: "public" | "private" }
67 | { ok: false; reason: "invalid-payload" | "not-found" | "not-member" };
68 
69function compareRepositoriesByName(
70 left: RepositoriesIslandSummary,
71 right: RepositoriesIslandSummary
72): number {
73 const namespaceOrder = left.namespaceSlug.localeCompare(right.namespaceSlug);
74 if (namespaceOrder !== 0) return namespaceOrder;
75 return left.slug.localeCompare(right.slug);
76}
77 
78export function RepositoriesIsland({
79 primaryNamespaceSlug,
80 repositories: initialRepos,
81}: RepositoriesIslandProps) {
82 const [repositories, setRepositories] = useState(initialRepos);
83 const [showForm, setShowForm] = useState(false);
84 const [pendingId, setPendingId] = useState<string | null>(null);
85 const [error, setError] = useState<string | null>(null);
86 
87 const broadcast = (next: RepositoriesIslandSummary[]) => {
88 setRepositories(next);
89 if (typeof window !== "undefined") {
90 const detail: RepositoriesChangedDetail = { repositories: next };
91 window.dispatchEvent(new CustomEvent(REPOSITORIES_CHANGED_EVENT, { detail }));
92 }
93 };
94 
95 async function handleCreate(payload: {
96 namespaceSlug: string;
97 slug: string;
98 visibility: "public" | "private";
99 }): Promise<boolean> {
100 setError(null);
101 try {
102 const res = await fetch("/auth/api/repositories", {
103 method: "POST",
104 credentials: "same-origin",
105 headers: { "Content-Type": "application/json" },
106 body: JSON.stringify(payload),
107 });
108 const body = (await res.json().catch(() => null)) as CreateResult | null;
109 if (!body) {
110 setError("Could not create repository");
111 return false;
112 }
113 if (!body.ok) {
114 setError(describeCreateReason(body.reason));
115 return false;
116 }
117 const next = [
118 ...repositories,
119 {
120 id: body.id,
121 slug: body.slug,
122 namespaceSlug: body.namespaceSlug,
123 visibility: body.visibility,
124 updatedAt: body.updatedAt,
125 },
126 ].sort(compareRepositoriesByName);
127 broadcast(next);
128 return true;
129 } catch (err) {
130 setError(String(err));
131 return false;
132 }
133 }
134 
135 async function handleToggle(repo: RepositoriesIslandSummary) {
136 if (pendingId) return;
137 const next = repo.visibility === "private" ? "public" : "private";
138 if (next === "public") {
139 const confirmed = confirm(
140 `Make ${repo.namespaceSlug}/${repo.slug} public? Anyone will be able to read it.`
141 );
142 if (!confirmed) return;
143 }
144 setPendingId(repo.id);
145 setError(null);
146 try {
147 const res = await fetch(`/auth/api/repositories/${encodeURIComponent(repo.id)}`, {
148 method: "PATCH",
149 credentials: "same-origin",
150 headers: { "Content-Type": "application/json" },
151 body: JSON.stringify({ visibility: next }),
152 });
153 const body = (await res.json().catch(() => null)) as ToggleResult | null;
154 if (!body || !body.ok) {
155 const reason = body && !body.ok ? body.reason : "unknown";
156 setError(`Could not toggle visibility (${reason})`);
157 return;
158 }
159 const updated = repositories.map((r) =>
160 r.id === repo.id ? { ...r, visibility: body.visibility } : r
161 );
162 broadcast(updated);
163 } catch (err) {
164 setError(String(err));
165 } finally {
166 setPendingId(null);
167 }
168 }
169 
170 return (
171 <div className="grid gap-3">
172 {error ? <ErrorBanner>{error}</ErrorBanner> : null}
173 {repositories.length === 0 ? (
174 <div className="flex flex-col items-center gap-3">
175 <EmptyState
176 icon={<FolderGit2 className="h-5 w-5 text-zinc-400" aria-hidden="true" />}
177 title="Repositories you own will appear here."
178 detail="Create one below; once it exists, you can use a PAT to clone or push."
179 />
180 {!showForm ? (
181 <Button type="button" variant="primary" size="md" onClick={() => setShowForm(true)}>
182 <Plus className="h-4 w-4" aria-hidden="true" />
183 New repository
184 </Button>
185 ) : null}
186 </div>
187 ) : (
188 <div className="overflow-hidden rounded-2xl border border-zinc-300 dark:border-zinc-800/60">
189 <table>
190 <thead>
191 <tr>
192 <th>Name</th>
193 <th>Visibility</th>
194 <th className="text-right">Updated</th>
195 </tr>
196 </thead>
197 <tbody>
198 {repositories.map((repo) => (
199 <tr key={repo.id}>
200 <td>
201 <a
202 href={`/${repo.namespaceSlug}/${repo.slug}`}
203 className="font-mono text-sm text-zinc-900 no-underline hover:text-accent-600 dark:text-zinc-100 dark:hover:text-accent-400"
204 >
205 {repo.namespaceSlug}/{repo.slug}
206 </a>
207 </td>
208 <td>
209 <button
210 type="button"
211 onClick={() => void handleToggle(repo)}
212 disabled={pendingId === repo.id}
213 className={`inline-flex items-center transition-opacity hover:opacity-80 disabled:opacity-50 ${
214 repo.visibility === "private"
215 ? "text-amber-700 dark:text-amber-400"
216 : "text-emerald-700 dark:text-emerald-400"
217 }`}
218 title={`${repo.visibility === "private" ? "Private" : "Public"} - click to toggle`}
219 aria-label={`${repo.visibility === "private" ? "Private" : "Public"} repository - click to toggle`}
220 >
221 {repo.visibility === "private" ? (
222 <Lock className="h-3.5 w-3.5" aria-hidden="true" />
223 ) : (
224 <Globe className="h-3.5 w-3.5" aria-hidden="true" />
225 )}
226 </button>
227 </td>
228 <td className="text-right text-xs text-zinc-500">
229 {formatRelativeTime(repo.updatedAt)}
230 </td>
231 </tr>
232 ))}
233 </tbody>
234 </table>
235 </div>
236 )}
237
238 {showForm ? (
239 <CreateForm
240 primaryNamespaceSlug={primaryNamespaceSlug}
241 onCancel={() => setShowForm(false)}
242 onSubmit={async (payload) => {
243 const ok = await handleCreate(payload);
244 if (ok) setShowForm(false);
245 return ok;
246 }}
247 />
248 ) : repositories.length > 0 ? (
249 <div className="flex justify-end">
250 <Button type="button" variant="ghost" size="sm" onClick={() => setShowForm(true)}>
251 <Plus className="h-4 w-4" aria-hidden="true" />
252 New repository
253 </Button>
254 </div>
255 ) : null}
256 </div>
257 );
258}
259 
260function describeCreateReason(reason: Exclude<CreateResult, { ok: true }>["reason"]): string {
261 switch (reason) {
262 case "invalid-slug":
263 return "Slug must be lowercase letters, digits, and dashes (1-40 chars).";
264 case "invalid-visibility":
265 return "Pick a visibility option.";
266 case "namespace-not-found":
267 return "Namespace not found.";
268 case "not-member":
269 return "You are not a member of that namespace.";
270 case "slug-taken":
271 return "A repository with that slug already exists.";
272 }
273}
274 
275type CreateFormProps = {
276 primaryNamespaceSlug?: string;
277 onCancel: () => void;
278 onSubmit: (payload: {
279 namespaceSlug: string;
280 slug: string;
281 visibility: "public" | "private";
282 }) => Promise<boolean>;
283};
284 
285function CreateForm({ primaryNamespaceSlug, onCancel, onSubmit }: CreateFormProps) {
286 const [namespaceSlug, setNamespaceSlug] = useState(primaryNamespaceSlug ?? "");
287 const [slug, setSlug] = useState("");
288 const [visibility, setVisibility] = useState<"public" | "private">("private");
289 const [submitting, setSubmitting] = useState(false);
290 
291 const canSubmit = useMemo(() => {
292 if (submitting) return false;
293 return namespaceSlug.trim().length > 0 && slug.trim().length > 0;
294 }, [submitting, namespaceSlug, slug]);
295 
296 return (
297 <Card>
298 <form
299 className="grid gap-5"
300 onSubmit={async (event) => {
301 event.preventDefault();
302 if (!canSubmit) return;
303 setSubmitting(true);
304 try {
305 await onSubmit({
306 namespaceSlug: namespaceSlug.trim().toLowerCase(),
307 slug: slug.trim().toLowerCase(),
308 visibility,
309 });
310 } finally {
311 setSubmitting(false);
312 }
313 }}
314 >
315 <h3 className="m-0 text-xs font-semibold uppercase tracking-wider text-zinc-500">
316 New repository
317 </h3>
318 <Input
319 label="Namespace"
320 type="text"
321 value={namespaceSlug}
322 onChange={(event) => setNamespaceSlug(event.target.value)}
323 placeholder={primaryNamespaceSlug ?? "rachel"}
324 helperText="The namespace to create under (your handle by default)."
325 required
326 />
327 <Input
328 label="Slug"
329 type="text"
330 value={slug}
331 onChange={(event) => setSlug(event.target.value)}
332 placeholder="site"
333 helperText="Lowercase ASCII, dashes between letters/digits. 1-40 chars."
334 required
335 />
336 <VisibilityTiles visibility={visibility} onChange={setVisibility} />
337 <div className="flex items-center justify-end gap-2">
338 <Button type="button" variant="ghost" size="sm" onClick={onCancel}>
339 Cancel
340 </Button>
341 <Button type="submit" variant="primary" size="md" disabled={!canSubmit}>
342 <Plus className="h-4 w-4" aria-hidden="true" />
343 Create repository
344 </Button>
345 </div>
346 </form>
347 </Card>
348 );
349}
350 
351type VisibilityTilesProps = {
352 visibility: "public" | "private";
353 onChange: (next: "public" | "private") => void;
354};
355 
356// Mirrors the PermissionLevelTiles shape from `tokens.tsx` so the account
357// page reads as one cohesive control language.
358function VisibilityTiles({ visibility, onChange }: VisibilityTilesProps) {
359 const tile =
360 "group relative flex cursor-pointer flex-col gap-1 rounded-xl border border-zinc-300 p-4 transition-colors hover:border-zinc-400 has-[:checked]:border-accent-500 has-[:checked]:bg-accent-500/5 has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-accent-500/40 dark:border-zinc-800/60 dark:hover:border-zinc-700";
361 return (
362 <fieldset className="m-0 grid gap-3 border-0 p-0 sm:grid-cols-2">
363 <legend className="sr-only">Visibility</legend>
364 <label className={tile}>
365 <input
366 type="radio"
367 name="repo-visibility"
368 value="private"
369 className="sr-only"
370 checked={visibility === "private"}
371 onChange={() => onChange("private")}
372 />
373 <span className="flex items-center gap-2 font-mono text-[11px] font-semibold uppercase tracking-widest text-zinc-700 group-has-[:checked]:text-accent-600 dark:text-zinc-300 dark:group-has-[:checked]:text-accent-400">
374 <Lock className="h-3 w-3" aria-hidden="true" />
375 Private
376 </span>
377 <span className="text-sm text-zinc-600 dark:text-zinc-400">
378 Only namespace members can read.
379 </span>
380 </label>
381 <label className={tile}>
382 <input
383 type="radio"
384 name="repo-visibility"
385 value="public"
386 className="sr-only"
387 checked={visibility === "public"}
388 onChange={() => onChange("public")}
389 />
390 <span className="flex items-center gap-2 font-mono text-[11px] font-semibold uppercase tracking-widest text-zinc-700 group-has-[:checked]:text-accent-600 dark:text-zinc-300 dark:group-has-[:checked]:text-accent-400">
391 <Globe className="h-3 w-3" aria-hidden="true" />
392 Public
393 </span>
394 <span className="text-sm text-zinc-600 dark:text-zinc-400">Anyone can read.</span>
395 </label>
396 </fieldset>
397 );
398}
399 
400export function initRepositoriesIsland() {
401 hydrateIsland("repositories", RepositoriesIsland);
402}