OXYGENHUB

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; }