fabrica

hanna/fabrica

feat(web): new-repository page and top-bar + button; full-width activity graph

de619b7 · hanna committed on 2026-07-25

Add a signed-in-only /new page (name, description, visibility, default
branch) that creates the repo row and bare repo on disk, plus a + button in
the top bar (left of search) linking to it. Stretch the contribution heatmap
to fill the activity column via width:100% on the viewBox'd SVG.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
7 files changed · +212 −2UnifiedSplit
assets/base.css +11 −1
@@ -119,13 +119,16 @@ code, pre, .mono {
119119 }
120120
121121 /* Top-bar search: a growing input with an inline submit icon. */
122+/* The new-repo button and search group at the right of the top bar. */
123+.topbar-new {
124+ margin-left: auto;
125+}
122126 .topbar-search {
123127 display: flex;
124128 align-items: center;
125129 gap: 0.25rem;
126130 flex: 1;
127131 max-width: 28rem;
128- margin-left: auto;
129132 }
130133 .topbar-search input[type="search"] {
131134 width: 100%;
@@ -395,6 +398,11 @@ select:focus {
395398 display: none !important;
396399 }
397400
401+/* New-repository form: a comfortably narrow column. */
402+.new-repo {
403+ max-width: 42rem;
404+}
405+
398406 /* Centered auth card (sign in, invite activation). */
399407 .auth-wrap {
400408 display: flex;
@@ -595,6 +603,8 @@ select:focus {
595603 }
596604 .heatmap {
597605 display: block;
606+ width: 100%;
607+ height: auto;
598608 }
599609 .hm-label {
600610 fill: var(--fb-fg-muted);
crates/web/src/activity.rs +3 −1
@@ -205,8 +205,10 @@ impl Activity {
205205
206206 html! {
207207 div class="heatmap-wrap" {
208+ // width:100% + the viewBox aspect ratio stretches the graph to
209+ // fill the activity column (styled in base.css).
208210 svg class="heatmap" viewBox=(format!("0 0 {width} {height}"))
209- width=(width) height=(height) role="img"
211+ preserveAspectRatio="xMidYMid meet" role="img"
210212 aria-label=(format!("{} contributions in the last year", self.total)) {
211213 (PreEscaped(body))
212214 }
crates/web/src/icons.rs +2 −0
@@ -21,6 +21,7 @@ pub enum Icon {
2121 ChevronDown,
2222 Search,
2323 Copy,
24+ Plus,
2425 Box,
2526 Link,
2627 User,
@@ -61,6 +62,7 @@ impl Icon {
6162 r#"<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>"#
6263 }
6364 Icon::ChevronDown => r#"<path d="m6 9 6 6 6-6"/>"#,
65+ Icon::Plus => r#"<path d="M5 12h14"/><path d="M12 5v14"/>"#,
6466 Icon::Search => r#"<path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/>"#,
6567 Icon::Copy => {
6668 r#"<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>"#
crates/web/src/layout.rs +4 −0
@@ -72,6 +72,10 @@ fn topbar(chrome: &Chrome) -> Markup {
7272 button class="icon-btn" type="button" data-drawer-toggle aria-label="Menu"
7373 aria-controls="drawer" aria-expanded="false" { (icon(Icon::PanelLeft)) }
7474 span class="slug" { a href="/" { (chrome.instance_name) } }
75+ @if chrome.user.is_some() {
76+ a class="icon-btn topbar-new" href="/new" aria-label="New repository"
77+ title="New repository" { (icon(Icon::Plus)) }
78+ }
7579 form class="topbar-search" method="get" action="/search" role="search" {
7680 input type="search" name="q" placeholder="Search…" aria-label="Search";
7781 }
crates/web/src/lib.rs +4 −0
@@ -129,6 +129,10 @@ pub fn build_router(state: AppState) -> Router {
129129 Router::new()
130130 .nest_service("/api/v1", api)
131131 .route("/", get(pages::home))
132+ .route(
133+ "/new",
134+ get(pages::new_repo_form).post(pages::new_repo_submit),
135+ )
132136 .route("/explore", get(pages::explore))
133137 .route("/explore/users", get(pages::explore_users))
134138 .route("/login", get(pages::login_form).post(pages::login_submit))
crates/web/src/pages.rs +166 −0
@@ -246,6 +246,172 @@ async fn dashboard_body(state: &AppState, user: &User, q: &str) -> AppResult<Mar
246246 })
247247 }
248248
249+/// `GET /new` — the new-repository form (requires sign-in).
250+pub async fn new_repo_form(
251+ State(state): State<AppState>,
252+ RequireUser(user): RequireUser,
253+ jar: CookieJar,
254+ uri: Uri,
255+) -> Response {
256+ let (jar, chrome) = build_chrome(&state, jar, Some(user), uri.path().to_string());
257+ let body = new_repo_body(&state, &chrome.csrf, None, "");
258+ (jar, page(&chrome, "New repository", body)).into_response()
259+}
260+
261+/// New-repository form fields.
262+#[derive(Debug, Deserialize)]
263+pub struct NewRepoForm {
264+ /// The repo name (may include a group path).
265+ #[serde(default)]
266+ name: String,
267+ /// Optional one-line description.
268+ #[serde(default)]
269+ description: String,
270+ /// Visibility token (`public` | `internal` | `private`).
271+ #[serde(default)]
272+ visibility: String,
273+ /// Optional default branch override.
274+ #[serde(default)]
275+ default_branch: String,
276+ /// CSRF token.
277+ #[serde(rename = "_csrf")]
278+ csrf: String,
279+}
280+
281+/// `POST /new` — create the repository (row + bare repo on disk) and redirect.
282+pub async fn new_repo_submit(
283+ State(state): State<AppState>,
284+ RequireUser(user): RequireUser,
285+ jar: CookieJar,
286+ headers: HeaderMap,
287+ Form(form): Form<NewRepoForm>,
288+) -> Response {
289+ if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
290+ return AppError::Forbidden.into_response();
291+ }
292+ match create_repo_from_form(&state, &user, &form).await {
293+ Ok(path) => Redirect::to(&format!("/{}/{path}", user.username)).into_response(),
294+ Err(msg) => {
295+ let (jar, chrome) = build_chrome(&state, jar, Some(user), "/new".to_string());
296+ let body = new_repo_body(&state, &chrome.csrf, Some(&msg), form.name.trim());
297+ (
298+ axum::http::StatusCode::BAD_REQUEST,
299+ jar,
300+ page(&chrome, "New repository", body),
301+ )
302+ .into_response()
303+ }
304+ }
305+}
306+
307+/// Create the repo described by `form`, returning its path or a user-facing error.
308+async fn create_repo_from_form(
309+ state: &AppState,
310+ user: &User,
311+ form: &NewRepoForm,
312+) -> Result<String, String> {
313+ let name = form.name.trim();
314+ if name.is_empty() {
315+ return Err("Repository name is required.".to_string());
316+ }
317+ let (group, leaf) = match name.rsplit_once('/') {
318+ Some((g, l)) => (Some(g), l),
319+ None => (None, name),
320+ };
321+ let group_id = match group {
322+ Some(g) => Some(
323+ state
324+ .store
325+ .ensure_group_path(&user.id, g)
326+ .await
327+ .map_err(|e| e.to_string())?
328+ .id,
329+ ),
330+ None => None,
331+ };
332+ let branch = {
333+ let b = form.default_branch.trim();
334+ if b.is_empty() {
335+ state.config.instance.default_branch.clone()
336+ } else {
337+ b.to_string()
338+ }
339+ };
340+ let visibility = model::Visibility::from_token(&form.visibility).unwrap_or_else(|| {
341+ model::Visibility::from_token(state.config.instance.default_visibility.as_token())
342+ .unwrap_or(model::Visibility::Private)
343+ });
344+ let description = {
345+ let d = form.description.trim();
346+ (!d.is_empty()).then(|| d.to_string())
347+ };
348+ let repo = state
349+ .store
350+ .create_repo(store::NewRepo {
351+ owner_id: user.id.clone(),
352+ group_id,
353+ name: leaf.to_string(),
354+ path: name.to_string(),
355+ description,
356+ visibility,
357+ default_branch: branch.clone(),
358+ })
359+ .await
360+ .map_err(|e| match e {
361+ store::StoreError::Conflict { .. } => {
362+ "A repository with that name already exists.".to_string()
363+ }
364+ store::StoreError::Name(_) => {
365+ "Invalid name: use letters, digits, '-', '_', and '/' for groups.".to_string()
366+ }
367+ other => other.to_string(),
368+ })?;
369+
370+ let hook = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("fabrica"));
371+ if let Err(e) = git::create_bare(&state.config.storage.repo_dir, &repo.id, &branch, &hook) {
372+ let _ = state.store.delete_repo(&repo.id).await;
373+ return Err(format!("Could not create the repository on disk: {e}"));
374+ }
375+ Ok(repo.path)
376+}
377+
378+/// Render the new-repository form with an optional error and prefilled name.
379+fn new_repo_body(state: &AppState, csrf: &str, error: Option<&str>, name: &str) -> Markup {
380+ let default_vis = state.config.instance.default_visibility.as_token();
381+ let vis_option = |value: &str, label: &str| {
382+ html! { option value=(value) selected[value == default_vis] { (label) } }
383+ };
384+ html! {
385+ div class="new-repo" {
386+ h1 { "New repository" }
387+ @if let Some(err) = error {
388+ div class="notice notice-error" { (err) }
389+ }
390+ div class="card" {
391+ form method="post" action="/new" {
392+ input type="hidden" name="_csrf" value=(csrf);
393+ label for="name" { "Repository name" }
394+ input type="text" id="name" name="name" value=(name) required
395+ placeholder="my-project (or group/my-project)" autofocus;
396+ label for="description" { "Description" }
397+ input type="text" id="description" name="description"
398+ placeholder="Optional one-line description";
399+ label for="visibility" { "Visibility" }
400+ select id="visibility" name="visibility" {
401+ (vis_option("public", "Public — visible to everyone"))
402+ (vis_option("internal", "Internal — any signed-in user"))
403+ (vis_option("private", "Private — only you and collaborators"))
404+ }
405+ label for="default_branch" { "Default branch" }
406+ input type="text" id="default_branch" name="default_branch"
407+ placeholder=(state.config.instance.default_branch);
408+ button class="btn btn-primary" type="submit" { "Create repository" }
409+ }
410+ }
411+ }
412+ }
413+}
414+
249415 /// Sign-in form fields.
250416 #[derive(Debug, Deserialize)]
251417 pub struct LoginForm {
crates/web/src/tests.rs +22 −0
@@ -430,6 +430,28 @@ async fn repo_settings_are_owner_only() {
430430 }
431431
432432 #[tokio::test]
433+async fn new_repo_page_requires_auth_and_renders() {
434+ let (_state, app) = app().await;
435+ // Anonymous → redirected to sign in.
436+ let res = app.clone().oneshot(get("/new")).await.unwrap();
437+ assert_eq!(res.status(), StatusCode::SEE_OTHER);
438+ assert_eq!(res.headers().get(header::LOCATION).unwrap(), "/login");
439+
440+ // Signed in → the creation form.
441+ let cookie = login(&app).await;
442+ let req = Request::builder()
443+ .uri("/new")
444+ .header(header::COOKIE, cookie)
445+ .body(Body::empty())
446+ .unwrap();
447+ let res = app.oneshot(req).await.unwrap();
448+ assert_eq!(res.status(), StatusCode::OK);
449+ let html = body_string(res).await;
450+ assert!(html.contains("New repository"));
451+ assert!(html.contains("name=\"visibility\""));
452+}
453+
454+#[tokio::test]
433455 async fn settings_requires_authentication() {
434456 let (_state, app) = app().await;
435457 let res = app.oneshot(get("/settings")).await.unwrap();