fabrica

hanna/fabrica

feat(web): create-menu repo choosers and live label chips

d13bc51 · hanna committed on 2026-07-25

The + menu now always offers New issue and New pull request, linking to
/new/issue and /new/pull chooser pages that list your repositories (with the
feature enabled) to pick from. The new-issue label dropdown mirrors checked
labels into a live chip list beside it as you select/deselect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
6 files changed · +120 −27UnifiedSplit
assets/base.css +12 −0
@@ -177,6 +177,18 @@ code, pre, .mono {
177177 white-space: nowrap;
178178 }
179179 /* Multi-label dropdown on the new-issue form. */
180+.label-dropdown-wrap {
181+ display: flex;
182+ align-items: center;
183+ flex-wrap: wrap;
184+ gap: 0.4rem;
185+ margin-bottom: 0.5rem;
186+}
187+.label-dropdown-chips {
188+ display: flex;
189+ flex-wrap: wrap;
190+ gap: 0.35rem;
191+}
180192 .label-dropdown {
181193 display: inline-block;
182194 position: relative;
assets/fabrica.js +18 −0
@@ -65,6 +65,24 @@
6565 });
6666 });
6767
68+ // ---- Label dropdown: mirror checked labels into chips ----
69+ document.querySelectorAll("[data-label-dropdown]").forEach(function (dd) {
70+ var chips = dd.parentNode.querySelector("[data-label-chips]");
71+ if (!chips) return;
72+ function sync() {
73+ chips.textContent = "";
74+ dd.querySelectorAll('input[type="checkbox"]:checked').forEach(function (cb) {
75+ var chip = document.createElement("span");
76+ chip.className = "label-chip";
77+ chip.style.setProperty("--label-color", cb.getAttribute("data-label-color") || "#888");
78+ chip.textContent = cb.getAttribute("data-label-name") || cb.value;
79+ chips.appendChild(chip);
80+ });
81+ }
82+ dd.addEventListener("change", sync);
83+ sync();
84+ });
85+
6886 // ---- Progressive link fields ----
6987 // Show the filled links plus one empty slot; reveal more (up to the max) via
7088 // the Add link button. Without JS every slot is visible.
crates/web/src/issues.rs +15 −9
@@ -504,20 +504,26 @@ pub(crate) fn label_form(
504504 }
505505
506506 /// A `<details>` dropdown of label checkboxes, for selecting several labels when
507-/// opening an issue. The checkboxes submit with the surrounding form.
507+/// opening an issue. The checkboxes submit with the surrounding form; JS mirrors
508+/// the checked ones into a live chip list beside the dropdown.
508509 fn label_dropdown(repo_labels: &[Label]) -> Markup {
509510 html! {
510- details class="menu label-dropdown" data-menu {
511- summary class="btn" { "Select labels" (icon(Icon::ChevronDown)) }
512- div class="menu-popover label-menu" {
513- @for l in repo_labels {
514- label class="checkbox" {
515- input type="checkbox" name="label" value=(l.id);
516- " " span class="label-swatch" style=(format!("--label-color: {}", css_color(&l.color))) {}
517- " " (l.name)
511+ div class="label-dropdown-wrap" {
512+ details class="menu label-dropdown" data-menu data-label-dropdown {
513+ summary class="btn" { "Select labels " (icon(Icon::ChevronDown)) }
514+ div class="menu-popover label-menu" {
515+ @for l in repo_labels {
516+ label class="checkbox" {
517+ input type="checkbox" name="label" value=(l.id)
518+ data-label-name=(l.name)
519+ data-label-color=(css_color(&l.color));
520+ " " span class="label-swatch" style=(format!("--label-color: {}", css_color(&l.color))) {}
521+ " " (l.name)
522+ }
518523 }
519524 }
520525 }
526+ span class="label-dropdown-chips" data-label-chips {}
521527 }
522528 }
523529 }
crates/web/src/layout.rs +10 −18
@@ -84,11 +84,11 @@ fn topbar(chrome: &Chrome) -> Markup {
8484 }
8585 }
8686
87-/// The create (`+`) popover: New repository always, plus New issue / New pull
88-/// request when the viewer is inside a repository. A native `<details>`, so it
89-/// opens without JavaScript; `fabrica.js` adds outside-click and Escape closing.
90-fn create_menu(chrome: &Chrome) -> Markup {
91- let repo_base = repo_base(&chrome.path);
87+/// The create (`+`) popover: New repository, New issue, and New pull request. The
88+/// issue/PR items go to a chooser that lets the viewer pick a repository. A native
89+/// `<details>`, so it opens without JavaScript; `fabrica.js` adds outside-click
90+/// and Escape closing.
91+fn create_menu(_chrome: &Chrome) -> Markup {
9292 html! {
9393 details class="topbar-create menu" data-menu {
9494 summary class="icon-btn" aria-label="Create new" title="Create new" {
@@ -98,25 +98,17 @@ fn create_menu(chrome: &Chrome) -> Markup {
9898 a class="menu-item" role="menuitem" href="/new" {
9999 (icon(Icon::Box)) span { "New repository" }
100100 }
101- @if let Some(base) = &repo_base {
102- a class="menu-item" role="menuitem" href=(format!("{base}/-/issues/new")) {
103- (icon(Icon::Issue)) span { "New issue" }
104- }
105- a class="menu-item" role="menuitem" href=(format!("{base}/-/pulls/new")) {
106- (icon(Icon::GitPull)) span { "New pull request" }
107- }
101+ a class="menu-item" role="menuitem" href="/new/issue" {
102+ (icon(Icon::Issue)) span { "New issue" }
103+ }
104+ a class="menu-item" role="menuitem" href="/new/pull" {
105+ (icon(Icon::GitPull)) span { "New pull request" }
108106 }
109107 }
110108 }
111109 }
112110 }
113111
114-/// The repository base path (everything before `/-/`) when `path` is a repo view,
115-/// else `None`. Used to target the create menu's New issue / New pull request.
116-fn repo_base(path: &str) -> Option<String> {
117- path.split_once("/-/").map(|(base, _)| base.to_string())
118-}
119-
120112 /// The off-canvas navigation drawer.
121113 fn drawer(chrome: &Chrome) -> Markup {
122114 html! {
crates/web/src/lib.rs +2 −0
@@ -138,6 +138,8 @@ pub fn build_router(state: AppState) -> Router {
138138 "/new",
139139 get(pages::new_repo_form).post(pages::new_repo_submit),
140140 )
141+ .route("/new/issue", get(pages::new_issue_chooser))
142+ .route("/new/pull", get(pages::new_pull_chooser))
141143 .route("/explore", get(pages::explore))
142144 .route("/explore/users", get(pages::explore_users))
143145 .route("/login", get(pages::login_form).post(pages::login_submit))
crates/web/src/pages.rs +63 −0
@@ -248,6 +248,69 @@ async fn dashboard_body(state: &AppState, user: &User, q: &str, uri: &Uri) -> Ap
248248 })
249249 }
250250
251+/// `GET /new/issue` — choose a repository to open an issue on.
252+pub async fn new_issue_chooser(
253+ State(state): State<AppState>,
254+ RequireUser(user): RequireUser,
255+ jar: CookieJar,
256+ uri: Uri,
257+) -> AppResult<Response> {
258+ let body = repo_chooser(&state, &user, "issues", "issue").await?;
259+ let (jar, chrome) = build_chrome(&state, jar, Some(user), uri.path().to_string());
260+ Ok((jar, page(&chrome, "New issue", body)).into_response())
261+}
262+
263+/// `GET /new/pull` — choose a repository to open a pull request on.
264+pub async fn new_pull_chooser(
265+ State(state): State<AppState>,
266+ RequireUser(user): RequireUser,
267+ jar: CookieJar,
268+ uri: Uri,
269+) -> AppResult<Response> {
270+ let body = repo_chooser(&state, &user, "pulls", "pull request").await?;
271+ let (jar, chrome) = build_chrome(&state, jar, Some(user), uri.path().to_string());
272+ Ok((jar, page(&chrome, "New pull request", body)).into_response())
273+}
274+
275+/// A "pick a repository" list linking each of the viewer's repos (with the
276+/// relevant feature enabled) to its new-issue / new-pull form.
277+async fn repo_chooser(state: &AppState, user: &User, seg: &str, noun: &str) -> AppResult<Markup> {
278+ let repos = state.store.repos_by_owner(&user.id).await?;
279+ let usable: Vec<_> = repos
280+ .iter()
281+ .filter(|r| {
282+ if seg == "pulls" {
283+ r.pulls_enabled
284+ } else {
285+ r.issues_enabled
286+ }
287+ })
288+ .collect();
289+ Ok(html! {
290+ div class="new-repo" {
291+ h1 { "New " (noun) }
292+ div class="card" {
293+ p class="muted field-hint" { "Choose a repository." }
294+ @if usable.is_empty() {
295+ p class="muted" { "You have no repositories with " (noun) "s enabled." }
296+ } @else {
297+ ul class="repo-list" {
298+ @for r in &usable {
299+ li {
300+ a class="repo-row" href={ "/" (user.username) "/" (r.path) "/-/" (seg) "/new" } {
301+ span class="repo-row-name" {
302+ (icon(Icon::Box)) span { (r.path) }
303+ }
304+ }
305+ }
306+ }
307+ }
308+ }
309+ }
310+ }
311+ })
312+}
313+
251314 /// `GET /new` — the new-repository form (requires sign-in).
252315 pub async fn new_repo_form(
253316 State(state): State<AppState>,