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 {
177 white-space: nowrap;177 white-space: nowrap;
178}178}
179/* Multi-label dropdown on the new-issue form. */179/* 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}
180.label-dropdown {192.label-dropdown {
181 display: inline-block;193 display: inline-block;
182 position: relative;194 position: relative;
assets/fabrica.js +18 −0
@@ -65,6 +65,24 @@
65 });65 });
66 });66 });
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
68 // ---- Progressive link fields ----86 // ---- Progressive link fields ----
69 // Show the filled links plus one empty slot; reveal more (up to the max) via87 // Show the filled links plus one empty slot; reveal more (up to the max) via
70 // the Add link button. Without JS every slot is visible.88 // 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(
504}504}
505505
506/// A `<details>` dropdown of label checkboxes, for selecting several labels when506/// 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.
508fn label_dropdown(repo_labels: &[Label]) -> Markup {509fn label_dropdown(repo_labels: &[Label]) -> Markup {
509 html! {510 html! {
510 details class="menu label-dropdown" data-menu {511 div class="label-dropdown-wrap" {
511 summary class="btn" { "Select labels" (icon(Icon::ChevronDown)) }512 details class="menu label-dropdown" data-menu data-label-dropdown {
512 div class="menu-popover label-menu" {513 summary class="btn" { "Select labels " (icon(Icon::ChevronDown)) }
513 @for l in repo_labels {514 div class="menu-popover label-menu" {
514 label class="checkbox" {515 @for l in repo_labels {
515 input type="checkbox" name="label" value=(l.id);516 label class="checkbox" {
516 " " span class="label-swatch" style=(format!("--label-color: {}", css_color(&l.color))) {}517 input type="checkbox" name="label" value=(l.id)
517 " " (l.name)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 }
518 }523 }
519 }524 }
520 }525 }
526 span class="label-dropdown-chips" data-label-chips {}
521 }527 }
522 }528 }
523}529}
crates/web/src/layout.rs +10 −18
@@ -84,11 +84,11 @@ fn topbar(chrome: &Chrome) -> Markup {
84 }84 }
85}85}
8686
87/// The create (`+`) popover: New repository always, plus New issue / New pull87/// The create (`+`) popover: New repository, New issue, and New pull request. The
88/// request when the viewer is inside a repository. A native `<details>`, so it88/// issue/PR items go to a chooser that lets the viewer pick a repository. A native
89/// opens without JavaScript; `fabrica.js` adds outside-click and Escape closing.89/// `<details>`, so it opens without JavaScript; `fabrica.js` adds outside-click
90fn create_menu(chrome: &Chrome) -> Markup {90/// and Escape closing.
91 let repo_base = repo_base(&chrome.path);91fn create_menu(_chrome: &Chrome) -> Markup {
92 html! {92 html! {
93 details class="topbar-create menu" data-menu {93 details class="topbar-create menu" data-menu {
94 summary class="icon-btn" aria-label="Create new" title="Create new" {94 summary class="icon-btn" aria-label="Create new" title="Create new" {
@@ -98,25 +98,17 @@ fn create_menu(chrome: &Chrome) -> Markup {
98 a class="menu-item" role="menuitem" href="/new" {98 a class="menu-item" role="menuitem" href="/new" {
99 (icon(Icon::Box)) span { "New repository" }99 (icon(Icon::Box)) span { "New repository" }
100 }100 }
101 @if let Some(base) = &repo_base {101 a class="menu-item" role="menuitem" href="/new/issue" {
102 a class="menu-item" role="menuitem" href=(format!("{base}/-/issues/new")) {102 (icon(Icon::Issue)) span { "New issue" }
103 (icon(Icon::Issue)) span { "New issue" }103 }
104 }104 a class="menu-item" role="menuitem" href="/new/pull" {
105 a class="menu-item" role="menuitem" href=(format!("{base}/-/pulls/new")) {105 (icon(Icon::GitPull)) span { "New pull request" }
106 (icon(Icon::GitPull)) span { "New pull request" }
107 }
108 }106 }
109 }107 }
110 }108 }
111 }109 }
112}110}
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.
116fn repo_base(path: &str) -> Option<String> {
117 path.split_once("/-/").map(|(base, _)| base.to_string())
118}
119
120/// The off-canvas navigation drawer.112/// The off-canvas navigation drawer.
121fn drawer(chrome: &Chrome) -> Markup {113fn drawer(chrome: &Chrome) -> Markup {
122 html! {114 html! {
crates/web/src/lib.rs +2 −0
@@ -138,6 +138,8 @@ pub fn build_router(state: AppState) -> Router {
138 "/new",138 "/new",
139 get(pages::new_repo_form).post(pages::new_repo_submit),139 get(pages::new_repo_form).post(pages::new_repo_submit),
140 )140 )
141 .route("/new/issue", get(pages::new_issue_chooser))
142 .route("/new/pull", get(pages::new_pull_chooser))
141 .route("/explore", get(pages::explore))143 .route("/explore", get(pages::explore))
142 .route("/explore/users", get(pages::explore_users))144 .route("/explore/users", get(pages::explore_users))
143 .route("/login", get(pages::login_form).post(pages::login_submit))145 .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
248 })248 })
249}249}
250250
251/// `GET /new/issue` — choose a repository to open an issue on.
252pub 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.
264pub 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.
277async 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
251/// `GET /new` — the new-repository form (requires sign-in).314/// `GET /new` — the new-repository form (requires sign-in).
252pub async fn new_repo_form(315pub async fn new_repo_form(
253 State(state): State<AppState>,316 State(state): State<AppState>,