web/app/new/page.tsxblame
View source
13a9fd11"use client";
13a9fd12
13a9fd13import Link from "next/link";
13a9fd14import { useEffect, useState } from "react";
13a9fd15import { useRouter } from "next/navigation";
13a9fd16import { orgs as orgsApi, repos as reposApi, type Org } from "@/lib/api";
13a9fd17import { useAuth } from "@/lib/auth";
13a9fd18import { Skeleton } from "@/app/components/skeleton";
13a9fd19import { Select } from "@/app/components/ui";
13a9fd110
13a9fd111export default function NewRepositoryPage() {
549b8a612 const cardStyle = {
549b8a613 backgroundColor: "var(--bg-card)",
549b8a614 border: "1px solid var(--border-subtle)",
549b8a615 minHeight: "19.5rem",
549b8a616 } as const;
13a9fd117 const { user, loading } = useAuth();
13a9fd118 const router = useRouter();
13a9fd119 const [orgList, setOrgList] = useState<Org[]>([]);
13a9fd120 const [orgsLoaded, setOrgsLoaded] = useState(false);
13a9fd121 const [repoName, setRepoName] = useState("");
13a9fd122 const [repoDesc, setRepoDesc] = useState("");
13a9fd123 const [repoOwner, setRepoOwner] = useState("");
13a9fd124 const [creating, setCreating] = useState(false);
13a9fd125 const [error, setError] = useState("");
13a9fd126 const [requestedOwner, setRequestedOwner] = useState("");
13a9fd127
13a9fd128 useEffect(() => {
13a9fd129 document.title = "New Repository";
13a9fd130 }, []);
13a9fd131
13a9fd132 useEffect(() => {
13a9fd133 const value = new URLSearchParams(window.location.search).get("owner");
13a9fd134 setRequestedOwner(value?.trim() ?? "");
13a9fd135 }, []);
13a9fd136
13a9fd137 useEffect(() => {
13a9fd138 if (!loading && !user) {
6dd74de39 router.push(`/login?redirect=${encodeURIComponent(window.location.pathname + window.location.search)}`);
13a9fd140 }
13a9fd141 }, [loading, user, router]);
13a9fd142
13a9fd143 useEffect(() => {
13a9fd144 if (!user) return;
13a9fd145 setOrgsLoaded(false);
13a9fd146 orgsApi
13a9fd147 .list()
13a9fd148 .then(({ orgs }) => setOrgList(orgs))
13a9fd149 .catch(() => setOrgList([]))
13a9fd150 .finally(() => setOrgsLoaded(true));
13a9fd151 }, [user]);
13a9fd152
13a9fd153 useEffect(() => {
13a9fd154 if (!user || !requestedOwner) return;
13a9fd155 if (requestedOwner === user.username) {
13a9fd156 setRepoOwner(user.username);
13a9fd157 return;
13a9fd158 }
13a9fd159 if (orgList.some((org) => org.name === requestedOwner)) {
13a9fd160 setRepoOwner(requestedOwner);
13a9fd161 }
13a9fd162 }, [user, requestedOwner, orgList]);
13a9fd163
13a9fd164 useEffect(() => {
13a9fd165 if (!user) return;
13a9fd166 setRepoOwner((prev) => prev || user.username);
13a9fd167 }, [user]);
13a9fd168
13a9fd169 async function handleCreateRepo(e: React.FormEvent) {
13a9fd170 e.preventDefault();
13a9fd171 if (!repoName.trim()) return;
13a9fd172 if (!user) return;
13a9fd173 setCreating(true);
13a9fd174 setError("");
13a9fd175 try {
13a9fd176 const { repo } = await reposApi.create({
13a9fd177 name: repoName.trim(),
13a9fd178 description: repoDesc.trim() || undefined,
13a9fd179 owner: repoOwner && repoOwner !== user.username ? repoOwner : undefined,
13a9fd180 });
13a9fd181 router.push(`/${repo.owner_name}/${repo.name}`);
13a9fd182 } catch (err: unknown) {
13a9fd183 setError(err instanceof Error ? err.message : "Failed to create repository");
13a9fd184 } finally {
13a9fd185 setCreating(false);
13a9fd186 }
13a9fd187 }
13a9fd188
13a9fd189 if (loading || (user && !orgsLoaded)) {
13a9fd190 return (
13a9fd191 <div className="max-w-xl mx-auto px-4 py-8">
13a9fd192 <div
549b8a693 className="p-4 space-y-2.5"
549b8a694 style={cardStyle}
13a9fd195 >
549b8a696 <Skeleton width="7.5rem" height="1.5rem" />
549b8a697 <Skeleton width="2.2rem" height="1rem" />
13a9fd198 <Skeleton width="100%" height="2.25rem" />
549b8a699 <Skeleton width="5.8rem" height="1rem" />
13a9fd1100 <Skeleton width="100%" height="2.25rem" />
549b8a6101 <Skeleton width="4.8rem" height="1rem" />
13a9fd1102 <Skeleton width="100%" height="2.25rem" />
13a9fd1103 <div className="flex items-center gap-2">
13a9fd1104 <Skeleton width="8.5rem" height="2rem" />
13a9fd1105 <Skeleton width="4.5rem" height="2rem" />
13a9fd1106 </div>
13a9fd1107 </div>
13a9fd1108 </div>
13a9fd1109 );
13a9fd1110 }
13a9fd1111
13a9fd1112 if (!user) return null;
13a9fd1113
13a9fd1114 const backHref = requestedOwner ? `/${requestedOwner}` : "/";
13a9fd1115
13a9fd1116 return (
13a9fd1117 <div className="max-w-xl mx-auto px-4 py-8">
13a9fd1118 <form
13a9fd1119 onSubmit={handleCreateRepo}
13a9fd1120 className="p-4 space-y-2.5"
549b8a6121 style={cardStyle}
13a9fd1122 >
13a9fd1123 <h1 className="text-base">New repository</h1>
13a9fd1124
13a9fd1125 <label className="block text-xs" style={{ color: "var(--text-muted)" }}>
13a9fd1126 Owner
13a9fd1127 </label>
13a9fd1128 <Select
13a9fd1129 value={repoOwner}
13a9fd1130 onChange={(e) => setRepoOwner(e.target.value)}
13a9fd1131 options={[
13a9fd1132 { value: user.username, label: user.username },
13a9fd1133 ...orgList.map((org) => ({
13a9fd1134 value: org.name,
13a9fd1135 label: org.display_name || org.name,
13a9fd1136 })),
13a9fd1137 ]}
13a9fd1138 aria-label="Owner"
13a9fd1139 />
13a9fd1140
13a9fd1141 <label className="block text-xs" style={{ color: "var(--text-muted)" }}>
13a9fd1142 Repository name
13a9fd1143 </label>
13a9fd1144 <input
13a9fd1145 value={repoName}
13a9fd1146 onChange={(e) => setRepoName(e.target.value)}
13a9fd1147 className="w-full px-3 py-2 text-sm focus:outline-none"
13a9fd1148 style={{
13a9fd1149 backgroundColor: "var(--bg-input)",
13a9fd1150 border: "1px solid var(--border-subtle)",
13a9fd1151 color: "var(--text-primary)",
13a9fd1152 outline: "none",
13a9fd1153 boxShadow: "none",
13a9fd1154 }}
13a9fd1155 required
13a9fd1156 autoFocus
13a9fd1157 aria-label="Repository name"
13a9fd1158 />
13a9fd1159
13a9fd1160 <label className="block text-xs" style={{ color: "var(--text-muted)" }}>
13a9fd1161 Description
13a9fd1162 </label>
13a9fd1163 <input
13a9fd1164 value={repoDesc}
13a9fd1165 onChange={(e) => setRepoDesc(e.target.value)}
13a9fd1166 className="w-full px-3 py-2 text-sm focus:outline-none"
13a9fd1167 style={{
13a9fd1168 backgroundColor: "var(--bg-input)",
13a9fd1169 border: "1px solid var(--border-subtle)",
13a9fd1170 color: "var(--text-primary)",
13a9fd1171 outline: "none",
13a9fd1172 boxShadow: "none",
13a9fd1173 }}
13a9fd1174 aria-label="Description"
13a9fd1175 />
13a9fd1176
13a9fd1177 {error && (
13a9fd1178 <div
13a9fd1179 className="text-sm px-3 py-2"
13a9fd1180 style={{
13a9fd1181 backgroundColor: "var(--error-bg)",
13a9fd1182 border: "1px solid var(--error-border)",
13a9fd1183 color: "var(--error-text)",
13a9fd1184 }}
13a9fd1185 >
13a9fd1186 {error}
13a9fd1187 </div>
13a9fd1188 )}
13a9fd1189
13a9fd1190 <div className="flex items-center gap-2">
13a9fd1191 <button
13a9fd1192 type="submit"
13a9fd1193 disabled={creating}
13a9fd1194 className="px-3 py-1.5 text-sm"
13a9fd1195 style={{
13a9fd1196 backgroundColor: "var(--accent)",
13a9fd1197 color: "var(--accent-text)",
13a9fd1198 opacity: creating ? 0.6 : 1,
13a9fd1199 }}
13a9fd1200 >
13a9fd1201 {creating ? "Creating..." : "Create repository"}
13a9fd1202 </button>
13a9fd1203 <Link
13a9fd1204 href={backHref}
13a9fd1205 className="px-3 py-1.5 text-sm"
13a9fd1206 style={{
13a9fd1207 backgroundColor: "var(--bg-inset)",
13a9fd1208 border: "1px solid var(--border-subtle)",
13a9fd1209 color: "var(--text-secondary)",
13a9fd1210 }}
13a9fd1211 >
13a9fd1212 Cancel
13a9fd1213 </Link>
13a9fd1214 </div>
13a9fd1215 </form>
13a9fd1216 </div>
13a9fd1217 );
13a9fd1218}