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 {
119}119}
120120
121/* Top-bar search: a growing input with an inline submit icon. */121/* 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}
122.topbar-search {126.topbar-search {
123 display: flex;127 display: flex;
124 align-items: center;128 align-items: center;
125 gap: 0.25rem;129 gap: 0.25rem;
126 flex: 1;130 flex: 1;
127 max-width: 28rem;131 max-width: 28rem;
128 margin-left: auto;
129}132}
130.topbar-search input[type="search"] {133.topbar-search input[type="search"] {
131 width: 100%;134 width: 100%;
@@ -395,6 +398,11 @@ select:focus {
395 display: none !important;398 display: none !important;
396}399}
397400
401/* New-repository form: a comfortably narrow column. */
402.new-repo {
403 max-width: 42rem;
404}
405
398/* Centered auth card (sign in, invite activation). */406/* Centered auth card (sign in, invite activation). */
399.auth-wrap {407.auth-wrap {
400 display: flex;408 display: flex;
@@ -595,6 +603,8 @@ select:focus {
595}603}
596.heatmap {604.heatmap {
597 display: block;605 display: block;
606 width: 100%;
607 height: auto;
598}608}
599.hm-label {609.hm-label {
600 fill: var(--fb-fg-muted);610 fill: var(--fb-fg-muted);
crates/web/src/activity.rs +3 −1
@@ -205,8 +205,10 @@ impl Activity {
205205
206 html! {206 html! {
207 div class="heatmap-wrap" {207 div class="heatmap-wrap" {
208 // width:100% + the viewBox aspect ratio stretches the graph to
209 // fill the activity column (styled in base.css).
208 svg class="heatmap" viewBox=(format!("0 0 {width} {height}"))210 svg class="heatmap" viewBox=(format!("0 0 {width} {height}"))
209 width=(width) height=(height) role="img"211 preserveAspectRatio="xMidYMid meet" role="img"
210 aria-label=(format!("{} contributions in the last year", self.total)) {212 aria-label=(format!("{} contributions in the last year", self.total)) {
211 (PreEscaped(body))213 (PreEscaped(body))
212 }214 }
crates/web/src/icons.rs +2 −0
@@ -21,6 +21,7 @@ pub enum Icon {
21 ChevronDown,21 ChevronDown,
22 Search,22 Search,
23 Copy,23 Copy,
24 Plus,
24 Box,25 Box,
25 Link,26 Link,
26 User,27 User,
@@ -61,6 +62,7 @@ impl Icon {
61 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"/>"#62 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"/>"#
62 }63 }
63 Icon::ChevronDown => r#"<path d="m6 9 6 6 6-6"/>"#,64 Icon::ChevronDown => r#"<path d="m6 9 6 6 6-6"/>"#,
65 Icon::Plus => r#"<path d="M5 12h14"/><path d="M12 5v14"/>"#,
64 Icon::Search => r#"<path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/>"#,66 Icon::Search => r#"<path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/>"#,
65 Icon::Copy => {67 Icon::Copy => {
66 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"/>"#68 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 {
72 button class="icon-btn" type="button" data-drawer-toggle aria-label="Menu"72 button class="icon-btn" type="button" data-drawer-toggle aria-label="Menu"
73 aria-controls="drawer" aria-expanded="false" { (icon(Icon::PanelLeft)) }73 aria-controls="drawer" aria-expanded="false" { (icon(Icon::PanelLeft)) }
74 span class="slug" { a href="/" { (chrome.instance_name) } }74 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 }
75 form class="topbar-search" method="get" action="/search" role="search" {79 form class="topbar-search" method="get" action="/search" role="search" {
76 input type="search" name="q" placeholder="Search…" aria-label="Search";80 input type="search" name="q" placeholder="Search…" aria-label="Search";
77 }81 }
crates/web/src/lib.rs +4 −0
@@ -129,6 +129,10 @@ pub fn build_router(state: AppState) -> Router {
129 Router::new()129 Router::new()
130 .nest_service("/api/v1", api)130 .nest_service("/api/v1", api)
131 .route("/", get(pages::home))131 .route("/", get(pages::home))
132 .route(
133 "/new",
134 get(pages::new_repo_form).post(pages::new_repo_submit),
135 )
132 .route("/explore", get(pages::explore))136 .route("/explore", get(pages::explore))
133 .route("/explore/users", get(pages::explore_users))137 .route("/explore/users", get(pages::explore_users))
134 .route("/login", get(pages::login_form).post(pages::login_submit))138 .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
246 })246 })
247}247}
248248
249/// `GET /new` — the new-repository form (requires sign-in).
250pub 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)]
263pub 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.
282pub 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.
308async 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.
379fn 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
249/// Sign-in form fields.415/// Sign-in form fields.
250#[derive(Debug, Deserialize)]416#[derive(Debug, Deserialize)]
251pub struct LoginForm {417pub struct LoginForm {
crates/web/src/tests.rs +22 −0
@@ -430,6 +430,28 @@ async fn repo_settings_are_owner_only() {
430}430}
431431
432#[tokio::test]432#[tokio::test]
433async 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]
433async fn settings_requires_authentication() {455async fn settings_requires_authentication() {
434 let (_state, app) = app().await;456 let (_state, app) = app().await;
435 let res = app.oneshot(get("/settings")).await.unwrap();457 let res = app.oneshot(get("/settings")).await.unwrap();