6.5 KB219 lines
Blame
1"use client";
2
3import Link from "next/link";
4import { useEffect, useState } from "react";
5import { useRouter } from "next/navigation";
6import { orgs as orgsApi, repos as reposApi, type Org } from "@/lib/api";
7import { useAuth } from "@/lib/auth";
8import { Skeleton } from "@/app/components/skeleton";
9import { Select } from "@/app/components/ui";
10
11export default function NewRepositoryPage() {
12 const cardStyle = {
13 backgroundColor: "var(--bg-card)",
14 border: "1px solid var(--border-subtle)",
15 minHeight: "19.5rem",
16 } as const;
17 const { user, loading } = useAuth();
18 const router = useRouter();
19 const [orgList, setOrgList] = useState<Org[]>([]);
20 const [orgsLoaded, setOrgsLoaded] = useState(false);
21 const [repoName, setRepoName] = useState("");
22 const [repoDesc, setRepoDesc] = useState("");
23 const [repoOwner, setRepoOwner] = useState("");
24 const [creating, setCreating] = useState(false);
25 const [error, setError] = useState("");
26 const [requestedOwner, setRequestedOwner] = useState("");
27
28 useEffect(() => {
29 document.title = "New Repository";
30 }, []);
31
32 useEffect(() => {
33 const value = new URLSearchParams(window.location.search).get("owner");
34 setRequestedOwner(value?.trim() ?? "");
35 }, []);
36
37 useEffect(() => {
38 if (!loading && !user) {
39 router.push(`/login?redirect=${encodeURIComponent(window.location.pathname + window.location.search)}`);
40 }
41 }, [loading, user, router]);
42
43 useEffect(() => {
44 if (!user) return;
45 setOrgsLoaded(false);
46 orgsApi
47 .list()
48 .then(({ orgs }) => setOrgList(orgs))
49 .catch(() => setOrgList([]))
50 .finally(() => setOrgsLoaded(true));
51 }, [user]);
52
53 useEffect(() => {
54 if (!user || !requestedOwner) return;
55 if (requestedOwner === user.username) {
56 setRepoOwner(user.username);
57 return;
58 }
59 if (orgList.some((org) => org.name === requestedOwner)) {
60 setRepoOwner(requestedOwner);
61 }
62 }, [user, requestedOwner, orgList]);
63
64 useEffect(() => {
65 if (!user) return;
66 setRepoOwner((prev) => prev || user.username);
67 }, [user]);
68
69 async function handleCreateRepo(e: React.FormEvent) {
70 e.preventDefault();
71 if (!repoName.trim()) return;
72 if (!user) return;
73 setCreating(true);
74 setError("");
75 try {
76 const { repo } = await reposApi.create({
77 name: repoName.trim(),
78 description: repoDesc.trim() || undefined,
79 owner: repoOwner && repoOwner !== user.username ? repoOwner : undefined,
80 });
81 router.push(`/${repo.owner_name}/${repo.name}`);
82 } catch (err: unknown) {
83 setError(err instanceof Error ? err.message : "Failed to create repository");
84 } finally {
85 setCreating(false);
86 }
87 }
88
89 if (loading || (user && !orgsLoaded)) {
90 return (
91 <div className="max-w-xl mx-auto px-4 py-8">
92 <div
93 className="p-4 space-y-2.5"
94 style={cardStyle}
95 >
96 <Skeleton width="7.5rem" height="1.5rem" />
97 <Skeleton width="2.2rem" height="1rem" />
98 <Skeleton width="100%" height="2.25rem" />
99 <Skeleton width="5.8rem" height="1rem" />
100 <Skeleton width="100%" height="2.25rem" />
101 <Skeleton width="4.8rem" height="1rem" />
102 <Skeleton width="100%" height="2.25rem" />
103 <div className="flex items-center gap-2">
104 <Skeleton width="8.5rem" height="2rem" />
105 <Skeleton width="4.5rem" height="2rem" />
106 </div>
107 </div>
108 </div>
109 );
110 }
111
112 if (!user) return null;
113
114 const backHref = requestedOwner ? `/${requestedOwner}` : "/";
115
116 return (
117 <div className="max-w-xl mx-auto px-4 py-8">
118 <form
119 onSubmit={handleCreateRepo}
120 className="p-4 space-y-2.5"
121 style={cardStyle}
122 >
123 <h1 className="text-base">New repository</h1>
124
125 <label className="block text-xs" style={{ color: "var(--text-muted)" }}>
126 Owner
127 </label>
128 <Select
129 value={repoOwner}
130 onChange={(e) => setRepoOwner(e.target.value)}
131 options={[
132 { value: user.username, label: user.username },
133 ...orgList.map((org) => ({
134 value: org.name,
135 label: org.display_name || org.name,
136 })),
137 ]}
138 aria-label="Owner"
139 />
140
141 <label className="block text-xs" style={{ color: "var(--text-muted)" }}>
142 Repository name
143 </label>
144 <input
145 value={repoName}
146 onChange={(e) => setRepoName(e.target.value)}
147 className="w-full px-3 py-2 text-sm focus:outline-none"
148 style={{
149 backgroundColor: "var(--bg-input)",
150 border: "1px solid var(--border-subtle)",
151 color: "var(--text-primary)",
152 outline: "none",
153 boxShadow: "none",
154 }}
155 required
156 autoFocus
157 aria-label="Repository name"
158 />
159
160 <label className="block text-xs" style={{ color: "var(--text-muted)" }}>
161 Description
162 </label>
163 <input
164 value={repoDesc}
165 onChange={(e) => setRepoDesc(e.target.value)}
166 className="w-full px-3 py-2 text-sm focus:outline-none"
167 style={{
168 backgroundColor: "var(--bg-input)",
169 border: "1px solid var(--border-subtle)",
170 color: "var(--text-primary)",
171 outline: "none",
172 boxShadow: "none",
173 }}
174 aria-label="Description"
175 />
176
177 {error && (
178 <div
179 className="text-sm px-3 py-2"
180 style={{
181 backgroundColor: "var(--error-bg)",
182 border: "1px solid var(--error-border)",
183 color: "var(--error-text)",
184 }}
185 >
186 {error}
187 </div>
188 )}
189
190 <div className="flex items-center gap-2">
191 <button
192 type="submit"
193 disabled={creating}
194 className="px-3 py-1.5 text-sm"
195 style={{
196 backgroundColor: "var(--accent)",
197 color: "var(--accent-text)",
198 opacity: creating ? 0.6 : 1,
199 }}
200 >
201 {creating ? "Creating..." : "Create repository"}
202 </button>
203 <Link
204 href={backHref}
205 className="px-3 py-1.5 text-sm"
206 style={{
207 backgroundColor: "var(--bg-inset)",
208 border: "1px solid var(--border-subtle)",
209 color: "var(--text-secondary)",
210 }}
211 >
212 Cancel
213 </Link>
214 </div>
215 </form>
216 </div>
217 );
218}
219