| 1 | /** The home page: all repositories, GitHub-dashboard style. */ |
| 2 | import { useEffect, useState } from "react"; |
| 3 | import { |
| 4 | createRepo, |
| 5 | listRepos, |
| 6 | type Connection, |
| 7 | type Repo, |
| 8 | type RepoCreated, |
| 9 | type User, |
| 10 | } from "../client.ts"; |
| 11 | import { |
| 12 | Badge, |
| 13 | Button, |
| 14 | CopyButton, |
| 15 | ErrorBox, |
| 16 | ForkIcon, |
| 17 | Input, |
| 18 | RepoIcon, |
| 19 | Spinner, |
| 20 | } from "../components.tsx"; |
| 21 | import { timeAgo } from "../format.ts"; |
| 22 | import { href, Link } from "../router.tsx"; |
| 23 | |
| 24 | const RepoRow = ({ repo }: { repo: Repo }) => ( |
| 25 | <li className="flex items-start justify-between gap-4 border-b border-border-muted py-4 last:border-b-0"> |
| 26 | <div className="min-w-0"> |
| 27 | <div className="flex flex-wrap items-center gap-2"> |
| 28 | <RepoIcon className="shrink-0 text-fg-muted" /> |
| 29 | <Link |
| 30 | to={href(repo.owner, repo.name)} |
| 31 | className="truncate text-base font-semibold text-accent hover:underline" |
| 32 | > |
| 33 | {repo.owner}/{repo.name} |
| 34 | </Link> |
| 35 | {repo.forkOf !== null && ( |
| 36 | <Badge> |
| 37 | <span className="inline-flex items-center gap-1"> |
| 38 | <ForkIcon className="size-3" /> fork of {repo.forkOf} |
| 39 | </span> |
| 40 | </Badge> |
| 41 | )} |
| 42 | <Badge>{repo.public ? "Public" : "Private"}</Badge> |
| 43 | {repo.readOnly && <Badge tone="attention">read-only</Badge>} |
| 44 | {repo.status !== "ready" && <Badge tone="danger">{repo.status}</Badge>} |
| 45 | </div> |
| 46 | {repo.description !== null && repo.description.length > 0 && ( |
| 47 | <p className="mt-1 text-sm text-fg-muted">{repo.description}</p> |
| 48 | )} |
| 49 | {/* No size here: the list renders from the registry's denormalized |
| 50 | columns, which don't carry object stats (that would re-create the |
| 51 | one-DO-wake-per-row N+1 the list was built to avoid). */} |
| 52 | <p className="mt-1.5 text-xs text-fg-muted"> |
| 53 | created {timeAgo(repo.createdAt)} |
| 54 | </p> |
| 55 | </div> |
| 56 | </li> |
| 57 | ); |
| 58 | |
| 59 | /** Post-create panel: the remote to add. The password is one of your API keys. */ |
| 60 | const CreatedPanel = ({ created }: { created: RepoCreated }) => ( |
| 61 | <div className="mb-4 space-y-3 rounded-md border border-success/40 bg-success/5 p-4 text-sm"> |
| 62 | <p className="font-medium text-success"> |
| 63 | Repository {created.repo.owner}/{created.repo.name} created. Push with one |
| 64 | of your API keys as the password (Settings → API keys). |
| 65 | </p> |
| 66 | <div className="flex items-center gap-2"> |
| 67 | <code className="grow overflow-x-auto rounded bg-canvas-subtle px-2 py-1 font-mono text-xs"> |
| 68 | git remote add origin{" "} |
| 69 | {created.remote.replace("://", "://x:YOUR_API_KEY@")} |
| 70 | </code> |
| 71 | <CopyButton |
| 72 | text={`git remote add origin ${created.remote.replace("://", "://x:YOUR_API_KEY@")}`} |
| 73 | /> |
| 74 | </div> |
| 75 | </div> |
| 76 | ); |
| 77 | |
| 78 | const NewRepoForm = ({ |
| 79 | connection, |
| 80 | user, |
| 81 | onCreated, |
| 82 | }: { |
| 83 | connection: Connection; |
| 84 | user: User; |
| 85 | onCreated: (created: RepoCreated) => void; |
| 86 | }) => { |
| 87 | // Repositories are owned by a principal's id, so the owner is you. |
| 88 | const [owner, setOwner] = useState(user.id); |
| 89 | const [name, setName] = useState(""); |
| 90 | const [description, setDescription] = useState(""); |
| 91 | const [isPublic, setIsPublic] = useState(true); |
| 92 | const [error, setError] = useState<unknown>(null); |
| 93 | const [busy, setBusy] = useState(false); |
| 94 | |
| 95 | const submit = async () => { |
| 96 | setBusy(true); |
| 97 | setError(null); |
| 98 | try { |
| 99 | const created = await createRepo(connection, { |
| 100 | owner: owner.trim(), |
| 101 | name: name.trim(), |
| 102 | public: isPublic, |
| 103 | ...(description.trim() ? { description: description.trim() } : {}), |
| 104 | }); |
| 105 | onCreated(created); |
| 106 | } catch (cause) { |
| 107 | setError(cause); |
| 108 | } finally { |
| 109 | setBusy(false); |
| 110 | } |
| 111 | }; |
| 112 | |
| 113 | return ( |
| 114 | <form |
| 115 | className="mb-4 space-y-3 rounded-md border border-border-muted bg-canvas-subtle p-4" |
| 116 | onSubmit={(event) => { |
| 117 | event.preventDefault(); |
| 118 | void submit(); |
| 119 | }} |
| 120 | > |
| 121 | <div className="flex gap-2"> |
| 122 | <Input value={owner} onChange={setOwner} placeholder="owner" mono /> |
| 123 | <span className="self-center text-fg-muted">/</span> |
| 124 | <Input value={name} onChange={setName} placeholder="name" mono /> |
| 125 | </div> |
| 126 | <Input |
| 127 | value={description} |
| 128 | onChange={setDescription} |
| 129 | placeholder="Description (optional)" |
| 130 | /> |
| 131 | <label className="flex items-center gap-2 text-sm"> |
| 132 | <input |
| 133 | type="checkbox" |
| 134 | checked={isPublic} |
| 135 | onChange={(event) => setIsPublic(event.target.checked)} |
| 136 | /> |
| 137 | Public — anyone can read and clone without signing in |
| 138 | </label> |
| 139 | {error != null && <ErrorBox error={error} />} |
| 140 | <Button kind="primary" type="submit" disabled={busy || !owner || !name}> |
| 141 | {busy ? "Creating…" : "Create repository"} |
| 142 | </Button> |
| 143 | </form> |
| 144 | ); |
| 145 | }; |
| 146 | |
| 147 | export const ReposPage = ({ |
| 148 | connection, |
| 149 | user, |
| 150 | }: { |
| 151 | connection: Connection; |
| 152 | user: User | null; |
| 153 | }) => { |
| 154 | const [repos, setRepos] = useState<Repo[] | null>(null); |
| 155 | const [cursor, setCursor] = useState<string | null>(null); |
| 156 | const [error, setError] = useState<unknown>(null); |
| 157 | const [showNew, setShowNew] = useState(false); |
| 158 | const [created, setCreated] = useState<RepoCreated | null>(null); |
| 159 | |
| 160 | const load = async (nextCursor?: string) => { |
| 161 | try { |
| 162 | const page = await listRepos(connection, { |
| 163 | limit: 50, |
| 164 | ...(nextCursor ? { cursor: nextCursor } : {}), |
| 165 | }); |
| 166 | setRepos((existing) => |
| 167 | nextCursor ? [...(existing ?? []), ...page.items] : page.items, |
| 168 | ); |
| 169 | setCursor(page.hasMore ? page.nextCursor : null); |
| 170 | } catch (cause) { |
| 171 | setError(cause); |
| 172 | } |
| 173 | }; |
| 174 | |
| 175 | useEffect(() => { |
| 176 | void load(); |
| 177 | // eslint-disable-next-line react-hooks/exhaustive-deps |
| 178 | }, [connection.url, user?.id]); |
| 179 | |
| 180 | if (error != null) return <ErrorBox error={error} />; |
| 181 | if (repos === null) return <Spinner />; |
| 182 | |
| 183 | return ( |
| 184 | <div> |
| 185 | <div className="mb-4 flex items-center justify-between"> |
| 186 | <h1 className="text-xl font-semibold">Repositories</h1> |
| 187 | {user !== null && ( |
| 188 | <Button kind="primary" onClick={() => setShowNew((value) => !value)}> |
| 189 | New repository |
| 190 | </Button> |
| 191 | )} |
| 192 | </div> |
| 193 | {created !== null && <CreatedPanel created={created} />} |
| 194 | {showNew && user !== null && ( |
| 195 | <NewRepoForm |
| 196 | connection={connection} |
| 197 | user={user} |
| 198 | onCreated={(result) => { |
| 199 | setCreated(result); |
| 200 | setShowNew(false); |
| 201 | void load(); |
| 202 | }} |
| 203 | /> |
| 204 | )} |
| 205 | {repos.length === 0 ? ( |
| 206 | <div className="rounded-md border border-border-muted py-16 text-center text-fg-muted"> |
| 207 | No repositories yet — create one to get started. |
| 208 | </div> |
| 209 | ) : ( |
| 210 | <ul> |
| 211 | {repos.map((repo) => ( |
| 212 | <RepoRow key={repo.repoId} repo={repo} /> |
| 213 | ))} |
| 214 | </ul> |
| 215 | )} |
| 216 | {cursor !== null && ( |
| 217 | <div className="mt-4 flex justify-center"> |
| 218 | <Button onClick={() => void load(cursor)}>Load more</Button> |
| 219 | </div> |
| 220 | )} |
| 221 | </div> |
| 222 | ); |
| 223 | }; |