Issue page: GitHub-style sidebar menus and a full-width comment box cb71e18eb6f9
Assignees, Labels and Milestone show what is set and open a filterable checklist from a gear (people who can be assigned: the owner, collaborators and their teams, an owning organisation's members); the assign form takes the whole list (action=set) as well as add and remove. Assign yourself; moderation as quiet rows with icons (the GitHub look's sentence-case trick no longer reaches them); the comment box spans the conversation. Tests. closes #82
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-02 16:46 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
cb71e18eb6f9a9fe3a2a4dfaa0edb640b3417e121ab52c52625f0e551d4c4a07- Depends on
- 18e381ea88c2 19bb31ff1f5c 2bfe5ef66b0c 6a1780558353 749d9a5fcd9f a1b9869565aa b5668108487f d23464c0ac60 e1ec9fbf538e
- Depended on by
- 3b1bc27fe805 97b18869acf5 ea14c4ec5c3f 5d90e0e045e2 26d0d091deb7 7f283f8e8b97 9e50caedf4db 4f37afcc3583 8e96499f2705 4da8b828a822
- In bookmarks
- api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
- In tags
- v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 22 atoms, 0 tree operations, 8 files
Browse the tree at this change
modified CHANGELOG.md +2 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **The issue page's sidebar, laid out as GitHub's (#82).** Assignees, Labels and Milestone each show what is set under their heading (avatars, coloured label pills) with a gear opening a menu: a filterable checklist (people who can be assigned, with avatars; labels with their colour and description; a milestone) that applies in one go. "Assign yourself" when nobody is assigned. Close, Not planned, Lock and Pin are quiet rows with icons at the foot, evenly spaced, and the comment box is as wide as the conversation. |
|
| 8 | + | ||
| 7 | 9 | - **Disable and delete accounts (#81).** Administration offers Disable and Enable beside Ban: a disabled account is refused like a banned one (sign-in, sessions, tokens, pushes), and signing in says "Your account has been disabled. Contact the hub's administrator." Delete removes an account for good after its username is typed again: its tokens, keys and memberships go, what it wrote stays under its name, and the name stays retired so nobody can take it over. It is refused, naming them, while the account owns repositories or is the only owner of an organisation; nobody disables or deletes themselves or the last admin who can sign in. A deleted account's proposal reviews now stay, as its comments do. |
|
| 8 | 10 | ||
| 9 | 11 | - **Labels from `oh` (#80).** `oh proposal label N --add NAME --remove NAME` and `oh issue label N …` put labels on or take them off (triage), and `oh label list|create|delete` manage the repository's labels. Through the API, `PATCH /proposals/{n}` takes `labels` (the whole set, by name, every name checked before anything changes), and a proposal's JSON lists its labels. |
|
| … | 35 unchanged lines |
modified assets/github.css +5 −0
| … | 96 unchanged lines |
||
| 97 | 97 | :root[data-look="github"] form.card button[type=submit]:hover, :root[data-look="github"] button.primary-btn:hover { background: var(--gh-green-hover); color: #fff; filter: none; } |
|
| 98 | 98 | :root[data-look="github"] button.danger-btn { color: var(--bad); } |
|
| 99 | 99 | :root[data-look="github"] button.linkish { background: none; border: 0; padding: 0; box-shadow: none; color: var(--acc); text-transform: none; } |
|
| 100 | + | /* The issue sidebar's actions are quiet rows, not buttons; their first |
|
| 101 | + | letter is an icon, so the look's sentence-case trick must not apply. */ |
|
| 102 | + | :root[data-look="github"] aside.side button.side-action { background: none; border: 0; box-shadow: none; padding: 5px 0; text-transform: none; font-size: 13px; font-weight: 600; color: var(--ink); } |
|
| 103 | + | :root[data-look="github"] aside.side button.side-action:hover { background: none; color: var(--acc); } |
|
| 104 | + | :root[data-look="github"] .side-empty button.linkish { font-size: 12px; } |
|
| 100 | 105 | ||
| 101 | 106 | /* Boxes: rounded, one border, rows divided inside. */ |
|
| 102 | 107 | :root[data-look="github"] .panel, :root[data-look="github"] form.card, :root[data-look="github"] dl.facts, :root[data-look="github"] .table, |
|
| … | 68 unchanged lines |
modified assets/hub.js +19 −0
| … | 384 unchanged lines |
||
| 385 | 385 | }); |
|
| 386 | 386 | }); |
|
| 387 | 387 | } |
|
| 388 | + | ||
| 389 | + | /* Sidebar menus (oxygenhub#82): type to filter a menu's items; a click |
|
| 390 | + | outside an open menu closes it. */ |
|
| 391 | + | document.querySelectorAll("[data-menu-filter]").forEach(function (input) { |
|
| 392 | + | var items = input.closest(".menu-pop").querySelectorAll(".menu-item"); |
|
| 393 | + | input.addEventListener("input", function () { |
|
| 394 | + | var q = input.value.trim().toLowerCase(); |
|
| 395 | + | items.forEach(function (it) { it.hidden = q !== "" && it.textContent.toLowerCase().indexOf(q) < 0; }); |
|
| 396 | + | }); |
|
| 397 | + | }); |
|
| 398 | + | document.querySelectorAll("details.menu").forEach(function (d) { |
|
| 399 | + | d.addEventListener("toggle", function () { |
|
| 400 | + | var f = d.querySelector("[data-menu-filter]"); |
|
| 401 | + | if (d.open && f) f.focus(); |
|
| 402 | + | }); |
|
| 403 | + | }); |
|
| 404 | + | document.addEventListener("click", function (ev) { |
|
| 405 | + | document.querySelectorAll("details.menu[open]").forEach(function (d) { if (!d.contains(ev.target)) d.open = false; }); |
|
| 406 | + | }); |
|
| 388 | 407 | })(); |
modified crates/hub/src/db/accounts.rs +19 −0
| … | 404 unchanged lines |
||
| 405 | 405 | ||
| 406 | 406 | // ----------------------------------------------------------- collaborators |
|
| 407 | 407 | ||
| 408 | + | /// Who an issue of this repository can be assigned to, by username: its |
|
| 409 | + | /// owner (an account), its collaborators (directly or through a team) and, |
|
| 410 | + | /// when an organisation owns it, the organisation's members. Blocked |
|
| 411 | + | /// accounts are left out. |
|
| 412 | + | pub async fn assignable(pool: &Pool, repo_id: i64, owner: &str) -> sqlx::Result<Vec<String>> { |
|
| 413 | + | sqlx::query_scalar( |
|
| 414 | + | "SELECT username FROM accounts WHERE banned = 0 AND disabled = 0 AND id IN (\ |
|
| 415 | + | SELECT id FROM accounts WHERE username = ?1 \ |
|
| 416 | + | UNION SELECT account_id FROM collaborators WHERE repo_id = ?2 AND account_id IS NOT NULL \ |
|
| 417 | + | UNION SELECT tm.account_id FROM team_members tm JOIN collaborators c ON c.team_id = tm.team_id WHERE c.repo_id = ?2 \ |
|
| 418 | + | UNION SELECT m.account_id FROM org_members m JOIN organisations o ON o.id = m.org_id WHERE o.name = ?1) \ |
|
| 419 | + | ORDER BY username COLLATE NOCASE", |
|
| 420 | + | ) |
|
| 421 | + | .bind(owner) |
|
| 422 | + | .bind(repo_id) |
|
| 423 | + | .fetch_all(pool) |
|
| 424 | + | .await |
|
| 425 | + | } |
|
| 426 | + | ||
| 408 | 427 | #[derive(Clone, Debug)] |
|
| 409 | 428 | pub struct Collaborator { |
|
| 410 | 429 | pub account: Option<Account>, |
|
| … | 471 unchanged lines |
modified crates/hub/src/web/issues.rs +43 −8
| … | 307 unchanged lines |
||
| 308 | 308 | labels: Vec<idb::Label>, |
|
| 309 | 309 | all_labels: Vec<idb::Label>, |
|
| 310 | 310 | assignees: Vec<String>, |
|
| 311 | + | /// Who it can be assigned to, for triagers' menu (oxygenhub#82). |
|
| 312 | + | assignable: Vec<String>, |
|
| 311 | 313 | milestone: Option<idb::Milestone>, |
|
| 312 | 314 | milestones: Vec<(idb::Milestone, bool)>, |
|
| 313 | 315 | subscribed: bool, |
|
| … | 63 unchanged lines |
||
| 377 | 379 | let projects = super::projects::placements_for(app, viewer, &row, i.number).await; |
|
| 378 | 380 | let closed_by = super::chain::proposals_closing(app, &row, i.number).await; |
|
| 379 | 381 | let project_choices = super::projects::project_choices(app, viewer, &row, &projects).await; |
|
| 382 | + | let mut assignable = if can_triage { accounts::assignable(&app.db, row.id, &row.owner).await.map_err(e)? } else { Vec::new() }; |
|
| 383 | + | for a in &assignees { |
|
| 384 | + | if !assignable.iter().any(|x| x.eq_ignore_ascii_case(a)) { |
|
| 385 | + | assignable.push(a.clone()); |
|
| 386 | + | } |
|
| 387 | + | } |
|
| 380 | 388 | Ok(Page(IssuePage { |
|
| 381 | 389 | shell, |
|
| 382 | 390 | repo: RepoShell::new(app, &row, "issues").with_role(role), |
|
| … | 6 unchanged lines |
||
| 389 | 397 | labels, |
|
| 390 | 398 | all_labels, |
|
| 391 | 399 | assignees, |
|
| 400 | + | assignable, |
|
| 392 | 401 | milestone, |
|
| 393 | 402 | milestones, |
|
| 394 | 403 | subscribed, |
|
| … | 279 unchanged lines |
||
| 674 | 683 | pub struct AssignForm { |
|
| 675 | 684 | #[serde(rename = "_csrf")] |
|
| 676 | 685 | csrf_token: String, |
|
| 677 | - | username: String, |
|
| 686 | + | /// One or more; with `set`, the whole list of assignees. |
|
| 687 | + | #[serde(default)] |
|
| 688 | + | username: Vec<String>, |
|
| 689 | + | /// `add`, `remove`, or `set` (the menu: exactly these). |
|
| 678 | 690 | action: String, |
|
| 679 | 691 | } |
|
| 680 | 692 | ||
| … | 1 unchanged lines |
||
| 682 | 694 | State(app): State<Shared>, |
|
| 683 | 695 | viewer: Viewer, |
|
| 684 | 696 | Path((owner, name, number)): Path<(String, String, i64)>, |
|
| 685 | - | Form(f): Form<AssignForm>, |
|
| 697 | + | axum_extra::extract::Form(f): axum_extra::extract::Form<AssignForm>, |
|
| 686 | 698 | ) -> Result<Page<IssuePage>, Error> { |
|
| 687 | 699 | let shell = Shell::new(&app, &format!("/{owner}/{name}/issue/{number}"), &viewer); |
|
| 688 | 700 | let me = require(&viewer, &shell)?; |
|
| … | 2 unchanged lines |
||
| 691 | 703 | if role < Role::Triage { |
|
| 692 | 704 | return Err(Error::not_found(shell)); |
|
| 693 | 705 | } |
|
| 694 | - | let Some(target) = accounts::account_by_name(&app.db, f.username.trim()).await.map_err(|e| Error::from_db(shell.clone(), e))? else { |
|
| 695 | - | return detail(&app, shell, &viewer, &owner, &name, number, None, Some("No such account.".into())).await; |
|
| 706 | + | let e = |e| Error::from_db(shell.clone(), e); |
|
| 707 | + | let mut named = Vec::new(); |
|
| 708 | + | for n in f.username.iter().map(|n| n.trim()).filter(|n| !n.is_empty()) { |
|
| 709 | + | match accounts::account_by_name(&app.db, n).await.map_err(e)? { |
|
| 710 | + | Some(a) => named.push(a), |
|
| 711 | + | None => return detail(&app, shell, &viewer, &owner, &name, number, None, Some(format!("No account {n}."))).await, |
|
| 712 | + | } |
|
| 713 | + | } |
|
| 714 | + | let current = idb::assignees(&app.db, i.id).await.map_err(e)?; |
|
| 715 | + | let has = |a: &accounts::Account| current.iter().any(|c| c.eq_ignore_ascii_case(&a.username)); |
|
| 716 | + | // What to add and to take off: one or more names, or (from the menu) |
|
| 717 | + | // the whole list, compared with who is assigned now. |
|
| 718 | + | let (add, remove): (Vec<&accounts::Account>, Vec<String>) = match f.action.as_str() { |
|
| 719 | + | "add" => (named.iter().filter(|a| !has(a)).collect(), Vec::new()), |
|
| 720 | + | "remove" => (Vec::new(), named.iter().filter(|a| has(a)).map(|a| a.username.clone()).collect()), |
|
| 721 | + | "set" => ( |
|
| 722 | + | named.iter().filter(|a| !has(a)).collect(), |
|
| 723 | + | current.iter().filter(|c| !named.iter().any(|a| a.username.eq_ignore_ascii_case(c))).cloned().collect(), |
|
| 724 | + | ), |
|
| 725 | + | _ => return Err(Error::bad(shell, "unknown action")), |
|
| 696 | 726 | }; |
|
| 697 | - | let on = f.action == "add"; |
|
| 698 | - | idb::set_assignee(&app.db, i.id, target.id, on).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 699 | - | idb::add_event(&app.db, i.id, &me.account.username, if on { "assigned" } else { "unassigned" }, &target.username).await.ok(); |
|
| 700 | - | if on { |
|
| 727 | + | for n in &remove { |
|
| 728 | + | if let Some(a) = accounts::account_by_name(&app.db, n).await.map_err(e)? { |
|
| 729 | + | idb::set_assignee(&app.db, i.id, a.id, false).await.map_err(e)?; |
|
| 730 | + | idb::add_event(&app.db, i.id, &me.account.username, "unassigned", &a.username).await.ok(); |
|
| 731 | + | } |
|
| 732 | + | } |
|
| 733 | + | for target in add { |
|
| 734 | + | idb::set_assignee(&app.db, i.id, target.id, true).await.map_err(e)?; |
|
| 735 | + | idb::add_event(&app.db, i.id, &me.account.username, "assigned", &target.username).await.ok(); |
|
| 701 | 736 | notify::deliver( |
|
| 702 | 737 | &app, |
|
| 703 | 738 | Event { |
|
| … | 310 unchanged lines |
modified crates/hub/tests/issues.rs +14 −2
| … | 159 unchanged lines |
||
| 160 | 160 | let r = post(app, "/andy/demo/issue/1/labels", Some(&andy), &format!("_csrf={csrf_a}&label={label_id}")).await; |
|
| 161 | 161 | assert!(r.body.contains(">bug<"), "{}", r.body); |
|
| 162 | 162 | let r = post(app, "/andy/demo/issue/1/assign", Some(&andy), &format!("_csrf={csrf_a}&username=bob&action=add")).await; |
|
| 163 | - | assert!(r.body.contains("href=\"/bob\">bob</a>"), "{}", r.body); |
|
| 163 | + | assert!(r.body.contains("<li><a href=\"/bob\"><img class=\"avatar xs\""), "{}", r.body); |
|
| 164 | + | // oxygenhub#82: the sidebar's menu sets the whole list at once. |
|
| 165 | + | assert!(r.body.contains("Assign people to this issue") && r.body.contains("data-menu-filter"), "the assignees menu"); |
|
| 166 | + | let r = post(app, "/andy/demo/issue/1/assign", Some(&andy), &format!("_csrf={csrf_a}&action=set&username=andy&username=bob")).await; |
|
| 167 | + | let assigned = |db: sqlx::SqlitePool| async move { |
|
| 168 | + | let id: i64 = sqlx::query_scalar("SELECT id FROM issues WHERE number = 1").fetch_one(&db).await.unwrap(); |
|
| 169 | + | let mut a = db::issues::assignees(&db, id).await.unwrap(); |
|
| 170 | + | a.sort(); |
|
| 171 | + | a |
|
| 172 | + | }; |
|
| 173 | + | assert_eq!(assigned(app.db.clone()).await, ["andy", "bob"], "{}", r.status); |
|
| 174 | + | post(app, "/andy/demo/issue/1/assign", Some(&andy), &format!("_csrf={csrf_a}&action=set&username=bob")).await; |
|
| 175 | + | assert_eq!(assigned(app.db.clone()).await, ["bob"], "set takes off who is not listed"); |
|
| 164 | 176 | let ms_id: i64 = sqlx::query_scalar("SELECT id FROM milestones WHERE title = 'v1'").fetch_one(&app.db).await.unwrap(); |
|
| 165 | 177 | let r = post(app, "/andy/demo/issue/1/milestone", Some(&andy), &format!("_csrf={csrf_a}&milestone={ms_id}")).await; |
|
| 166 | 178 | assert!(r.body.contains(">v1<"), "{}", r.body); |
|
| 167 | - | assert!(post(app, "/andy/demo/issue/1/state", Some(&andy), &format!("_csrf={csrf_a}&action=pin")).await.body.contains("UNPIN")); |
|
| 179 | + | assert!(post(app, "/andy/demo/issue/1/state", Some(&andy), &format!("_csrf={csrf_a}&action=pin")).await.body.contains("Unpin issue")); |
|
| 168 | 180 | assert_eq!( |
|
| 169 | 181 | post( |
|
| 170 | 182 | app, |
|
| … | 661 unchanged lines |
modified templates/issue.html +56 −27
| … | 26 unchanged lines |
||
| 27 | 27 | {% endmatch %} |
|
| 28 | 28 | {% endfor %} |
|
| 29 | 29 | {% if shell.user.is_some() && (!i.locked || can_triage) %} |
|
| 30 | - | <form class="card wide" method="post" action="{{ repo.url }}/issue/{{ i.number }}/comment"> |
|
| 30 | + | <form class="card comment-form" method="post" action="{{ repo.url }}/issue/{{ i.number }}/comment"> |
|
| 31 | 31 | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 32 | 32 | <label for="body">Comment (Markdown)</label><textarea id="body" name="body" class="md" rows="5" required></textarea> |
|
| 33 | 33 | <div class="choice"> |
|
| … | 3 unchanged lines |
||
| 37 | 37 | </form> |
|
| 38 | 38 | {% else if i.locked %}<p class="empty">This issue is locked.</p>{% endif %} |
|
| 39 | 39 | </div> |
|
| 40 | - | <aside class="side"> |
|
| 40 | + | <aside class="side side-sections"> |
|
| 41 | 41 | {% if shell.user.is_some() %} |
|
| 42 | - | <h3>Subscription</h3> |
|
| 43 | - | <form method="post" action="{{ repo.url }}/issue/{{ i.number }}/subscribe"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit" class="small">{% if subscribed %}UNSUBSCRIBE{% else %}SUBSCRIBE{% endif %}</button></form> |
|
| 42 | + | <section class="side-sec"> |
|
| 43 | + | <div class="side-head"><h3>Notifications</h3></div> |
|
| 44 | + | <form method="post" action="{{ repo.url }}/issue/{{ i.number }}/subscribe"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit" class="small side-wide">{% if subscribed %}Unsubscribe{% else %}Subscribe{% endif %}</button></form> |
|
| 45 | + | <p class="side-note">{% if subscribed %}You're receiving notifications because you're subscribed.{% else %}You're not receiving notifications from this issue.{% endif %}</p> |
|
| 46 | + | </section> |
|
| 44 | 47 | {% endif %} |
|
| 45 | - | <h3>Labels</h3> |
|
| 46 | - | {% if labels.is_empty() %}<p class="mute small">None.</p>{% else %}<p>{% for l in labels %}<span class="label" style="--c: {{ l.colour }}" title="{{ l.description }}">{{ l.name }}</span> {% endfor %}</p>{% endif %} |
|
| 47 | - | {% if can_triage && !all_labels.is_empty() %} |
|
| 48 | - | <form class="tiny-form" method="post" action="{{ repo.url }}/issue/{{ i.number }}/labels"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 49 | - | {% for l in all_labels %}<label class="small"><input type="checkbox" name="label" value="{{ l.id }}"{% if labels.contains(l) %} checked{% endif %}> {{ l.name }}</label> {% endfor %} |
|
| 50 | - | <button type="submit" class="small">SET</button></form> |
|
| 51 | - | {% endif %} |
|
| 52 | - | <h3>Assignees</h3> |
|
| 53 | - | {% if assignees.is_empty() %}<p class="mute small">Nobody.</p>{% else %}<ul class="plain">{% for a in assignees %}<li><a href="/{{ a }}">{{ a }}</a></li>{% endfor %}</ul>{% endif %} |
|
| 54 | - | {% if can_triage %}<form class="tiny-form" method="post" action="{{ repo.url }}/issue/{{ i.number }}/assign"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="text" name="username" placeholder="username" size="12"> <button type="submit" name="action" value="add" class="small">ADD</button> <button type="submit" name="action" value="remove" class="small">REMOVE</button></form>{% endif %} |
|
| 55 | - | <h3>Milestone</h3> |
|
| 56 | - | {% if let Some(ms) = milestone %}<p><a href="{{ repo.url }}/milestones">{{ ms.title }}</a></p>{% else %}<p class="mute small">None.</p>{% endif %} |
|
| 57 | - | {% if can_triage && !milestones.is_empty() %}<form class="tiny-form" method="post" action="{{ repo.url }}/issue/{{ i.number }}/milestone"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><select name="milestone"><option value="">none</option>{% for (ms, sel) in milestones %}<option value="{{ ms.id }}"{% if *sel %} selected{% endif %}>{{ ms.title }}</option>{% endfor %}</select> <button type="submit" class="small">SET</button></form>{% endif %} |
|
| 58 | - | <h3>Projects</h3> |
|
| 59 | - | {% if projects.is_empty() %}<p class="mute small">None.</p>{% else %}<ul class="plain small">{% for (pr, col) in projects %}<li><a href="/projects/{{ pr.owner }}/{{ pr.number }}">{{ pr.title }}</a> <span class="mute">· {{ col }}</span>{% if project_choices.is_some() %}<form class="tiny" method="post" action="{{ repo.url }}/issue/{{ i.number }}/project"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="project" value="{{ pr.number }}"><button name="action" value="remove" class="linkish" title="Take it off this project">remove</button></form>{% endif %}</li>{% endfor %}</ul>{% endif %} |
|
| 60 | - | {% if let Some(choices) = project_choices %}{% if choices.is_empty() %}{% if projects.is_empty() %}<p class="small mute"><a href="/projects/{{ repo.owner }}">{{ repo.owner }}'s projects</a></p>{% endif %}{% else %}<form class="side-picker-inline" method="post" action="{{ repo.url }}/issue/{{ i.number }}/project"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><select name="project" aria-label="Project">{% for pr in choices %}<option value="{{ pr.number }}">{{ pr.title }}</option>{% endfor %}</select> <button name="action" value="add" class="small">Add</button></form>{% endif %}{% endif %} |
|
| 48 | + | <section class="side-sec"> |
|
| 49 | + | <div class="side-head"><h3>Assignees</h3> |
|
| 50 | + | {% if can_triage %}<details class="menu"><summary class="gear" title="Edit assignees" aria-label="Edit assignees"><svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M8 0a8.2 8.2 0 0 1 .7.03c.4.04.7.34.76.73l.2 1.37c.33.11.65.24.96.4l1.12-.83a.85.85 0 0 1 1.07.06c.34.3.65.63.94.97.25.3.27.73.06 1.06l-.83 1.12c.16.3.29.63.4.96l1.37.2c.39.06.69.37.73.76a8 8 0 0 1 0 1.4.85.85 0 0 1-.73.76l-1.37.2c-.11.33-.24.65-.4.96l.83 1.12c.21.33.19.76-.06 1.06-.29.34-.6.67-.94.97a.85.85 0 0 1-1.07.06l-1.12-.83c-.3.16-.63.29-.96.4l-.2 1.37a.85.85 0 0 1-.76.73 8 8 0 0 1-1.4 0 .85.85 0 0 1-.76-.73l-.2-1.37a5.6 5.6 0 0 1-.96-.4l-1.12.83a.85.85 0 0 1-1.07-.06 8 8 0 0 1-.94-.97.85.85 0 0 1-.06-1.06l.83-1.12a5.6 5.6 0 0 1-.4-.96l-1.37-.2A.85.85 0 0 1 0 8.7a8 8 0 0 1 0-1.4c.04-.39.34-.7.73-.76l1.37-.2c.11-.33.24-.65.4-.96l-.83-1.12a.85.85 0 0 1 .06-1.06c.29-.34.6-.67.94-.97a.85.85 0 0 1 1.07-.06l1.12.83c.3-.16.63-.29.96-.4l.2-1.37A.85.85 0 0 1 7.3.03 8 8 0 0 1 8 0Zm0 5a3 3 0 1 0 0 6 3 3 0 0 0 0-6Z"/></svg></summary> |
|
| 51 | + | <form class="menu-pop" method="post" action="{{ repo.url }}/issue/{{ i.number }}/assign"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="action" value="set"> |
|
| 52 | + | <p class="menu-title">Assign people to this issue</p> |
|
| 53 | + | <input class="menu-filter" type="search" placeholder="Filter people" aria-label="Filter people" data-menu-filter> |
|
| 54 | + | <div class="menu-list">{% for u in assignable %}<label class="menu-item"><input type="checkbox" name="username" value="{{ u }}"{% if assignees.contains(u) %} checked{% endif %}> <img class="avatar xs" src="/avatar/{{ u }}" alt="" loading="lazy"> <span class="menu-name">{{ u }}</span></label>{% endfor %} |
|
| 55 | + | {% if assignable.is_empty() %}<p class="menu-empty">Nobody with access to this repository yet.</p>{% endif %}</div> |
|
| 56 | + | <div class="menu-foot"><button type="submit" class="small primary-btn">Apply</button></div> |
|
| 57 | + | </form></details>{% endif %}</div> |
|
| 58 | + | {% if assignees.is_empty() %}<p class="side-empty">No one{% if can_triage %}{% if let Some(me) = shell.user %}: <form class="inline" method="post" action="{{ repo.url }}/issue/{{ i.number }}/assign"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="username" value="{{ me }}"><button name="action" value="add" class="linkish">assign yourself</button></form>{% endif %}{% endif %}</p> |
|
| 59 | + | {% else %}<ul class="side-people">{% for a in assignees %}<li><a href="/{{ a }}"><img class="avatar xs" src="/avatar/{{ a }}" alt="" loading="lazy"> {{ a }}</a></li>{% endfor %}</ul>{% endif %} |
|
| 60 | + | </section> |
|
| 61 | + | <section class="side-sec"> |
|
| 62 | + | <div class="side-head"><h3>Labels</h3> |
|
| 63 | + | {% if can_triage %}<details class="menu"><summary class="gear" title="Edit labels" aria-label="Edit labels"><svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M8 0a8.2 8.2 0 0 1 .7.03c.4.04.7.34.76.73l.2 1.37c.33.11.65.24.96.4l1.12-.83a.85.85 0 0 1 1.07.06c.34.3.65.63.94.97.25.3.27.73.06 1.06l-.83 1.12c.16.3.29.63.4.96l1.37.2c.39.06.69.37.73.76a8 8 0 0 1 0 1.4.85.85 0 0 1-.73.76l-1.37.2c-.11.33-.24.65-.4.96l.83 1.12c.21.33.19.76-.06 1.06-.29.34-.6.67-.94.97a.85.85 0 0 1-1.07.06l-1.12-.83c-.3.16-.63.29-.96.4l-.2 1.37a.85.85 0 0 1-.76.73 8 8 0 0 1-1.4 0 .85.85 0 0 1-.76-.73l-.2-1.37a5.6 5.6 0 0 1-.96-.4l-1.12.83a.85.85 0 0 1-1.07-.06 8 8 0 0 1-.94-.97.85.85 0 0 1-.06-1.06l.83-1.12a5.6 5.6 0 0 1-.4-.96l-1.37-.2A.85.85 0 0 1 0 8.7a8 8 0 0 1 0-1.4c.04-.39.34-.7.73-.76l1.37-.2c.11-.33.24-.65.4-.96l-.83-1.12a.85.85 0 0 1 .06-1.06c.29-.34.6-.67.94-.97a.85.85 0 0 1 1.07-.06l1.12.83c.3-.16.63-.29.96-.4l.2-1.37A.85.85 0 0 1 7.3.03 8 8 0 0 1 8 0Zm0 5a3 3 0 1 0 0 6 3 3 0 0 0 0-6Z"/></svg></summary> |
|
| 64 | + | <form class="menu-pop" method="post" action="{{ repo.url }}/issue/{{ i.number }}/labels"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 65 | + | <p class="menu-title">Apply labels to this issue</p> |
|
| 66 | + | {% if !all_labels.is_empty() %}<input class="menu-filter" type="search" placeholder="Filter labels" aria-label="Filter labels" data-menu-filter>{% endif %} |
|
| 67 | + | <div class="menu-list">{% for l in all_labels %}<label class="menu-item"><input type="checkbox" name="label" value="{{ l.id }}"{% if labels.contains(l) %} checked{% endif %}> <span class="swatch" style="--c: {{ l.colour }}"></span> <span class="menu-name">{{ l.name }}{% if !l.description.is_empty() %}<small>{{ l.description }}</small>{% endif %}</span></label>{% endfor %} |
|
| 68 | + | {% if all_labels.is_empty() %}<p class="menu-empty">This repository has no labels yet.</p>{% endif %}</div> |
|
| 69 | + | <div class="menu-foot"><a class="small" href="{{ repo.url }}/labels">Edit labels</a> <button type="submit" class="small primary-btn">Apply</button></div> |
|
| 70 | + | </form></details>{% endif %}</div> |
|
| 71 | + | {% if labels.is_empty() %}<p class="side-empty">None yet</p>{% else %}<p class="side-labels">{% for l in labels %}<span class="label" style="--c: {{ l.colour }}" title="{{ l.description }}">{{ l.name }}</span> {% endfor %}</p>{% endif %} |
|
| 72 | + | </section> |
|
| 73 | + | <section class="side-sec"> |
|
| 74 | + | <div class="side-head"><h3>Milestone</h3> |
|
| 75 | + | {% if can_triage && !milestones.is_empty() %}<details class="menu"><summary class="gear" title="Set milestone" aria-label="Set milestone"><svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M8 0a8.2 8.2 0 0 1 .7.03c.4.04.7.34.76.73l.2 1.37c.33.11.65.24.96.4l1.12-.83a.85.85 0 0 1 1.07.06c.34.3.65.63.94.97.25.3.27.73.06 1.06l-.83 1.12c.16.3.29.63.4.96l1.37.2c.39.06.69.37.73.76a8 8 0 0 1 0 1.4.85.85 0 0 1-.73.76l-1.37.2c-.11.33-.24.65-.4.96l.83 1.12c.21.33.19.76-.06 1.06-.29.34-.6.67-.94.97a.85.85 0 0 1-1.07.06l-1.12-.83c-.3.16-.63.29-.96.4l-.2 1.37a.85.85 0 0 1-.76.73 8 8 0 0 1-1.4 0 .85.85 0 0 1-.76-.73l-.2-1.37a5.6 5.6 0 0 1-.96-.4l-1.12.83a.85.85 0 0 1-1.07-.06 8 8 0 0 1-.94-.97.85.85 0 0 1-.06-1.06l.83-1.12a5.6 5.6 0 0 1-.4-.96l-1.37-.2A.85.85 0 0 1 0 8.7a8 8 0 0 1 0-1.4c.04-.39.34-.7.73-.76l1.37-.2c.11-.33.24-.65.4-.96l-.83-1.12a.85.85 0 0 1 .06-1.06c.29-.34.6-.67.94-.97a.85.85 0 0 1 1.07-.06l1.12.83c.3-.16.63-.29.96-.4l.2-1.37A.85.85 0 0 1 7.3.03 8 8 0 0 1 8 0Zm0 5a3 3 0 1 0 0 6 3 3 0 0 0 0-6Z"/></svg></summary> |
|
| 76 | + | <form class="menu-pop" method="post" action="{{ repo.url }}/issue/{{ i.number }}/milestone"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 77 | + | <p class="menu-title">Set milestone</p> |
|
| 78 | + | <div class="menu-list"><label class="menu-item"><input type="radio" name="milestone" value=""{% if milestone.is_none() %} checked{% endif %}> <span class="menu-name">No milestone</span></label> |
|
| 79 | + | {% for (ms, sel) in milestones %}<label class="menu-item"><input type="radio" name="milestone" value="{{ ms.id }}"{% if *sel %} checked{% endif %}> <span class="menu-name">{{ ms.title }}</span></label>{% endfor %}</div> |
|
| 80 | + | <div class="menu-foot"><button type="submit" class="small primary-btn">Apply</button></div> |
|
| 81 | + | </form></details>{% endif %}</div> |
|
| 82 | + | {% if let Some(ms) = milestone %}<p class="side-value"><a href="{{ repo.url }}/milestones">{{ ms.title }}</a></p>{% else %}<p class="side-empty">No milestone</p>{% endif %} |
|
| 83 | + | </section> |
|
| 84 | + | <section class="side-sec"> |
|
| 85 | + | <div class="side-head"><h3>Projects</h3></div> |
|
| 86 | + | {% if projects.is_empty() %}<p class="side-empty">None yet</p>{% else %}<ul class="plain small side-list">{% for (pr, col) in projects %}<li><a href="/projects/{{ pr.owner }}/{{ pr.number }}">{{ pr.title }}</a> <span class="mute">· {{ col }}</span>{% if project_choices.is_some() %}<form class="inline" method="post" action="{{ repo.url }}/issue/{{ i.number }}/project"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="project" value="{{ pr.number }}"><button name="action" value="remove" class="linkish" title="Take it off this project">remove</button></form>{% endif %}</li>{% endfor %}</ul>{% endif %} |
|
| 87 | + | {% if let Some(choices) = project_choices %}{% if choices.is_empty() %}{% if projects.is_empty() %}<p class="side-note"><a href="/projects/{{ repo.owner }}">{{ repo.owner }}'s projects</a></p>{% endif %}{% else %}<form class="side-picker-inline" method="post" action="{{ repo.url }}/issue/{{ i.number }}/project"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><select name="project" aria-label="Project">{% for pr in choices %}<option value="{{ pr.number }}">{{ pr.title }}</option>{% endfor %}</select> <button name="action" value="add" class="small">Add</button></form>{% endif %}{% endif %} |
|
| 88 | + | </section> |
|
| 61 | 89 | {% if can_triage %} |
|
| 62 | - | <h3>Moderation</h3> |
|
| 63 | - | <form class="tiny-form" method="post" action="{{ repo.url }}/issue/{{ i.number }}/state"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 64 | - | <div class="choice"> |
|
| 65 | - | {% if i.state == "open" %}<button type="submit" name="action" value="close" class="small">CLOSE</button><button type="submit" name="action" value="not_planned" class="small">NOT PLANNED</button>{% else %}<button type="submit" name="action" value="reopen" class="small">REOPEN</button>{% endif %} |
|
| 66 | - | <button type="submit" name="action" value="{% if i.locked %}unlock{% else %}lock{% endif %}" class="small">{% if i.locked %}UNLOCK{% else %}LOCK{% endif %}</button> |
|
| 67 | - | <button type="submit" name="action" value="{% if i.pinned %}unpin{% else %}pin{% endif %}" class="small">{% if i.pinned %}UNPIN{% else %}PIN{% endif %}</button> |
|
| 68 | - | </div></form> |
|
| 90 | + | <section class="side-sec side-actions"> |
|
| 91 | + | <form method="post" action="{{ repo.url }}/issue/{{ i.number }}/state"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 92 | + | {% if i.state == "open" %}<button type="submit" name="action" value="close" class="side-action"><span aria-hidden="true">✓</span> Close as completed</button> |
|
| 93 | + | <button type="submit" name="action" value="not_planned" class="side-action"><span aria-hidden="true">⊘</span> Close as not planned</button>{% else %}<button type="submit" name="action" value="reopen" class="side-action"><span aria-hidden="true">↺</span> Reopen issue</button>{% endif %} |
|
| 94 | + | <button type="submit" name="action" value="{% if i.locked %}unlock{% else %}lock{% endif %}" class="side-action"><span aria-hidden="true">🔒</span> {% if i.locked %}Unlock conversation{% else %}Lock conversation{% endif %}</button> |
|
| 95 | + | <button type="submit" name="action" value="{% if i.pinned %}unpin{% else %}pin{% endif %}" class="side-action"><span aria-hidden="true">📌</span> {% if i.pinned %}Unpin issue{% else %}Pin issue{% endif %}</button> |
|
| 96 | + | </form> |
|
| 97 | + | </section> |
|
| 69 | 98 | {% endif %} |
|
| 70 | 99 | </aside> |
|
| 71 | 100 | </div> |
|
| … | 1 unchanged lines |
modified templates/style.css +43 −0
| … | 661 unchanged lines |
||
| 662 | 662 | .side-picker-inline { display: flex; gap: 6px; align-items: center; margin-top: 4px; } |
|
| 663 | 663 | .side-picker-inline select { flex: 1; min-width: 0; } |
|
| 664 | 664 | @media (max-width: 760px) { .timeline::before { display: none; } .tl-item { grid-template-columns: 1fr; } .tl-avatar { display: none; } .timeline form.card.tl-review, .tl-editbox { margin-left: 0; } } |
|
| 665 | + | ||
| 666 | + | /* The issue page's sidebar, as GitHub lays it out (oxygenhub#82): sections |
|
| 667 | + | with a heading and a gear opening a filterable menu that applies in one |
|
| 668 | + | go; what is set is shown under the heading; moderation as quiet actions. */ |
|
| 669 | + | form.card.comment-form { max-width: none; } |
|
| 670 | + | .side-sections .side-sec { padding: 14px 0; border-bottom: 1px solid var(--line); } |
|
| 671 | + | .side-sections .side-sec:first-child { padding-top: 0; } |
|
| 672 | + | .side-sections .side-sec:last-child { border-bottom: 0; } |
|
| 673 | + | .side-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; } |
|
| 674 | + | .side-sections .side-head h3 { margin: 0; font-size: 12px; font-weight: 600; color: var(--mute); text-transform: none; letter-spacing: 0; } |
|
| 675 | + | .side-empty, .side-note { margin: 0; font-size: 12px; color: var(--mute); } |
|
| 676 | + | .side-empty button.linkish { font-size: inherit; } |
|
| 677 | + | .side-note { margin-top: 6px; } |
|
| 678 | + | .side-value { margin: 0; font-size: 14px; } |
|
| 679 | + | .side-labels { margin: 0; display: flex; flex-wrap: wrap; gap: 4px; } |
|
| 680 | + | .side-labels .label { margin: 0; } |
|
| 681 | + | .side-people { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; } |
|
| 682 | + | .side-people a { display: inline-flex; align-items: center; gap: 8px; color: var(--ink); font-weight: 600; font-size: 13px; } |
|
| 683 | + | .side-people img.avatar, .menu-item img.avatar { border-radius: 50%; } |
|
| 684 | + | .side-wide { width: 100%; } |
|
| 685 | + | form.inline { display: inline; margin: 0; } |
|
| 686 | + | details.menu { position: relative; } |
|
| 687 | + | details.menu > summary { list-style: none; cursor: pointer; color: var(--mute); display: inline-flex; padding: 3px; border-radius: 6px; } |
|
| 688 | + | details.menu > summary::-webkit-details-marker { display: none; } |
|
| 689 | + | details.menu > summary:hover, details.menu[open] > summary { color: var(--acc); background: var(--soft); } |
|
| 690 | + | .menu-pop { position: absolute; right: 0; top: 28px; z-index: 30; width: 290px; margin: 0; padding: 0; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; box-shadow: 0 8px 24px rgba(0, 0, 0, .18); } |
|
| 691 | + | .menu-title { margin: 0; padding: 9px 12px; font-size: 12px; font-weight: 600; color: var(--ink); } |
|
| 692 | + | .menu-filter { display: block; width: calc(100% - 20px); margin: 0 10px 10px; box-sizing: border-box; } |
|
| 693 | + | .menu-list { max-height: 300px; overflow-y: auto; border-top: 1px solid var(--line); } |
|
| 694 | + | aside.side .menu-item { display: flex; align-items: flex-start; gap: 10px; margin: 0; padding: 8px 12px; border-bottom: 1px solid var(--line); cursor: pointer; font-size: 13px; font-weight: 400; color: var(--ink); text-transform: none; letter-spacing: 0; } |
|
| 695 | + | aside.side .menu-item:hover { background: var(--soft); } |
|
| 696 | + | .menu-item input { margin: 2px 0 0; flex: none; } |
|
| 697 | + | .menu-name { display: flex; flex-direction: column; gap: 2px; min-width: 0; } |
|
| 698 | + | .menu-name small { color: var(--mute); font-size: 12px; } |
|
| 699 | + | .menu-empty { margin: 0; padding: 10px 12px; font-size: 12px; color: var(--mute); } |
|
| 700 | + | .menu-foot { display: flex; align-items: center; justify-content: flex-end; gap: 8px; padding: 8px 12px; } |
|
| 701 | + | .menu-foot a { margin-right: auto; } |
|
| 702 | + | .swatch { flex: none; width: 12px; height: 12px; margin-top: 3px; border-radius: 50%; background: var(--c); box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .15); } |
|
| 703 | + | .side-actions form { display: grid; gap: 2px; } |
|
| 704 | + | button.side-action { all: unset; cursor: pointer; display: flex; align-items: center; gap: 10px; padding: 5px 0; font-size: 13px; font-weight: 600; color: var(--ink); } |
|
| 705 | + | button.side-action span { width: 16px; text-align: center; color: var(--mute); } |
|
| 706 | + | button.side-action:hover { color: var(--acc); } |
|
| 707 | + | button.side-action:focus-visible { outline: 2px solid var(--acc); outline-offset: 2px; } |