1/** The home page: all repositories, GitHub-dashboard style. */
2import { useEffect, useState } from "react";
3import {
4 createRepo,
5 listRepos,
6 type Connection,
7 type Repo,
8 type RepoCreated,
9 type User,
10} from "../client.ts";
11import {
12 Badge,
13 Button,
14 CopyButton,
15 ErrorBox,
16 ForkIcon,
17 Input,
18 RepoIcon,
19 Spinner,
20} from "../components.tsx";
21import { timeAgo } from "../format.ts";
22import { href, Link } from "../router.tsx";
23
24const 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. */
60const 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
78const 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
147export 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};