File
Blob: src/client/islands/repositories.tsx
| 1 | /// <reference lib="dom" /> |
| 2 | |
| 3 | import { useMemo, useState } from "react"; |
| 4 | import { FolderGit2, Globe, Lock, Plus } from "lucide-react"; |
| 5 | |
| 6 | import { hydrateIsland } from "@/client/hydrate"; |
| 7 | import { 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. |
| 12 | export type RepositoriesIslandSummary = { |
| 13 | id: string; |
| 14 | slug: string; |
| 15 | namespaceSlug: string; |
| 16 | visibility: "public" | "private"; |
| 17 | updatedAt: number; |
| 18 | }; |
| 19 | |
| 20 | export 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. |
| 28 | export const REPOSITORIES_CHANGED_EVENT = "goc:repositories-changed"; |
| 29 | |
| 30 | export type RepositoriesChangedDetail = { |
| 31 | repositories: RepositoriesIslandSummary[]; |
| 32 | }; |
| 33 | |
| 34 | function 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 | |
| 46 | type 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 | |
| 65 | type ToggleResult = |
| 66 | | { ok: true; id: string; visibility: "public" | "private"; previous: "public" | "private" } |
| 67 | | { ok: false; reason: "invalid-payload" | "not-found" | "not-member" }; |
| 68 | |
| 69 | function 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 | |
| 78 | export 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 | |
| 260 | function 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 | |
| 275 | type 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 | |
| 285 | function 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 | |
| 351 | type 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. |
| 358 | function 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 | |
| 400 | export function initRepositoriesIsland() { |
| 401 | hydrateIsland("repositories", RepositoriesIsland); |
| 402 | } |