The store as its provider's icon; a tidier Projects page 7b26e6118bc0
A repository kept in its owner's bucket shows the provider's icon in the header (Google Cloud Storage, Amazon S3 or Azure Blob Storage) instead of a 'bucket' tag. Hovered, focused or tapped, it opens a card: the provider, and for the repository's admins the bucket (or account and container), the prefix, the region and the URL, with a link to Settings > Storage and its last check; others see only that the data is in the owner's own storage. CSS only. The icons are drawn for the hub in each provider's colour, not their logos. The Projects page has a filter box (title or description, ?q=) and the New project button in one bar, Open and Closed tabs with their counts (?state=closed), and a row per project with a board icon, its title, a private badge, '#N · updated' and its description. The new-project form opens from the button, above the list, instead of trailing it. closes #123, closes #124
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-10 18:27 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
7b26e6118bc0aa60ea3f2495fd7cf09f50f30da63472a67db06990408098b2d9- Depends on
- 18e381ea88c2 1f78655a0466 554ccacfc123 66fd9d6b1452 8ddebaa7e856 a024e800af4e c987c86513df d23464c0ac60 e1ec9fbf538e
- Depended on by
- c7067c7bda56 7f283f8e8b97 c8df33a6829f
- 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.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
- 28 atoms, 0 tree operations, 8 files
Browse the tree at this change
modified crates/hub/src/storage.rs +57 −0
| … | 55 unchanged lines |
||
| 56 | 56 | } |
|
| 57 | 57 | ||
| 58 | 58 | /// Where a repository's data lives, when it is not on this hub's disk. |
|
| 59 | + | /// What the repository header shows of a store (#123): which provider, |
|
| 60 | + | /// and, for the repository's admins, where in it. |
|
| 61 | + | #[derive(Clone, Debug, PartialEq)] |
|
| 62 | + | pub struct StoreBadge { |
|
| 63 | + | /// `gcs`, `s3` or `azure`: picks the icon. |
|
| 64 | + | pub kind: &'static str, |
|
| 65 | + | pub provider: &'static str, |
|
| 66 | + | /// The bucket, or for Azure the storage account and container. |
|
| 67 | + | pub place: String, |
|
| 68 | + | pub prefix: String, |
|
| 69 | + | pub region: Option<String>, |
|
| 70 | + | /// The URL as stored, query and all. |
|
| 71 | + | pub url: String, |
|
| 72 | + | } |
|
| 73 | + | ||
| 74 | + | impl StoreBadge { |
|
| 75 | + | /// The parts of a store URL; `None` for a scheme the hub does not |
|
| 76 | + | /// keep repositories on. |
|
| 77 | + | pub fn of(url: &str) -> Option<StoreBadge> { |
|
| 78 | + | let (scheme, rest) = url.split_once("://")?; |
|
| 79 | + | let (path, query) = rest.split_once('?').unwrap_or((rest, "")); |
|
| 80 | + | let region = query.split('&').find_map(|kv| kv.strip_prefix("region=")).filter(|r| !r.is_empty()).map(str::to_string); |
|
| 81 | + | let mut parts = path.trim_matches('/').splitn(if scheme == "az" { 3 } else { 2 }, '/'); |
|
| 82 | + | let (kind, provider, place) = match scheme { |
|
| 83 | + | "gs" => ("gcs", "Google Cloud Storage", parts.next()?.to_string()), |
|
| 84 | + | "s3" => ("s3", "Amazon S3", parts.next()?.to_string()), |
|
| 85 | + | "az" => ("azure", "Azure Blob Storage", format!("{} / {}", parts.next()?, parts.next()?)), |
|
| 86 | + | _ => return None, |
|
| 87 | + | }; |
|
| 88 | + | Some(StoreBadge { kind, provider, place, prefix: parts.next().unwrap_or("").to_string(), region, url: url.to_string() }) |
|
| 89 | + | } |
|
| 90 | + | } |
|
| 91 | + | ||
| 59 | 92 | pub fn location(app: &Shared, owner: &str, name: &str) -> Option<String> { |
|
| 60 | 93 | let dir = app.repos.dir(&crate::repos::RepoName::new(owner, name)?); |
|
| 61 | 94 | KNOWN.read().ok()?.as_ref()?.urls.get(&dir).cloned() |
|
| … | 280 unchanged lines |
||
| 342 | 375 | assert!(strict.permits("acme", Some("s3://acme-london/repos/x?region=eu-west-2")).is_ok()); |
|
| 343 | 376 | let e = strict.permits("acme", Some("s3://acme-virginia/x")).unwrap_err(); |
|
| 344 | 377 | assert!(e.contains("allows only these stores"), "{e}"); |
|
| 378 | + | } |
|
| 379 | + | } |
|
| 380 | + | ||
| 381 | + | #[cfg(test)] |
|
| 382 | + | mod badge_tests { |
|
| 383 | + | use super::StoreBadge; |
|
| 384 | + | ||
| 385 | + | #[test] |
|
| 386 | + | fn a_store_url_names_its_provider_place_prefix_and_region() { |
|
| 387 | + | let g = StoreBadge::of("gs://doomsdayone-oxy-repos/repos/tetris").unwrap(); |
|
| 388 | + | assert_eq!( |
|
| 389 | + | (g.kind, g.provider, g.place.as_str(), g.prefix.as_str(), g.region), |
|
| 390 | + | ("gcs", "Google Cloud Storage", "doomsdayone-oxy-repos", "repos/tetris", None) |
|
| 391 | + | ); |
|
| 392 | + | let s = StoreBadge::of("s3://my-bucket/repos/name?region=eu-west-2").unwrap(); |
|
| 393 | + | assert_eq!( |
|
| 394 | + | (s.kind, s.place.as_str(), s.prefix.as_str(), s.region.as_deref()), |
|
| 395 | + | ("s3", "my-bucket", "repos/name", Some("eu-west-2")) |
|
| 396 | + | ); |
|
| 397 | + | let a = StoreBadge::of("az://account/container/repos/x").unwrap(); |
|
| 398 | + | assert_eq!((a.kind, a.place.as_str(), a.prefix.as_str()), ("azure", "account / container", "repos/x")); |
|
| 399 | + | assert_eq!(StoreBadge::of("s3://bucket").unwrap().prefix, ""); |
|
| 400 | + | assert!(StoreBadge::of("dir:///tmp/x").is_none()); |
|
| 401 | + | assert!(StoreBadge::of("az://only-account").is_none()); |
|
| 345 | 402 | } |
|
| 346 | 403 | } |
modified crates/hub/src/web/mod.rs +4 −3
| … | 408 unchanged lines |
||
| 409 | 409 | pub archived: bool, |
|
| 410 | 410 | /// Whether the repository has a wiki (§6.11). |
|
| 411 | 411 | pub wiki: bool, |
|
| 412 | - | /// The bucket its data lives in, when not this hub's disk (#96). |
|
| 413 | - | pub stored_in: Option<String>, |
|
| 412 | + | /// The bucket its data lives in, when not this hub's disk (#96), as |
|
| 413 | + | /// the header's provider icon shows it (#123). |
|
| 414 | + | pub stored_in: Option<crate::storage::StoreBadge>, |
|
| 414 | 415 | /// Whether it has coverage reports, for the Coverage tab (#113). |
|
| 415 | 416 | pub coverage: bool, |
|
| 416 | 417 | } |
|
| … | 14 unchanged lines |
||
| 431 | 432 | open_proposals: row.open_proposals, |
|
| 432 | 433 | archived: row.archived, |
|
| 433 | 434 | wiki: crate::repos::RepoName::new(&row.owner, &row.name).is_some_and(|n| app.wikis.exists(&n)), |
|
| 434 | - | stored_in: crate::storage::location(app, &row.owner, &row.name), |
|
| 435 | + | stored_in: crate::storage::location(app, &row.owner, &row.name).and_then(|u| crate::storage::StoreBadge::of(&u)), |
|
| 435 | 436 | coverage: crate::coverage::has_reports(app, row.id), |
|
| 436 | 437 | } |
|
| 437 | 438 | } |
|
| … | 277 unchanged lines |
modified crates/hub/src/web/projects.rs +57 −7
| … | 6 unchanged lines |
||
| 7 | 7 | use crate::db::{self, projects as pdb}; |
|
| 8 | 8 | use crate::Shared; |
|
| 9 | 9 | use askama::Template; |
|
| 10 | - | use axum::extract::{Form, Path, State}; |
|
| 10 | + | use axum::extract::{Form, Path, Query, State}; |
|
| 11 | 11 | use axum::response::{IntoResponse, Redirect, Response}; |
|
| 12 | 12 | use serde::Deserialize; |
|
| 13 | 13 | ||
| … | 35 unchanged lines |
||
| 49 | 49 | pub struct ProjectsPage { |
|
| 50 | 50 | shell: Shell, |
|
| 51 | 51 | owner: String, |
|
| 52 | + | /// The projects shown: open or closed as `closed` says, matching `q`. |
|
| 52 | 53 | projects: Vec<pdb::Project>, |
|
| 53 | 54 | can_edit: bool, |
|
| 54 | 55 | error: Option<String>, |
|
| 55 | 56 | /// On your own page, the organisations you belong to: their boards |
|
| 56 | 57 | /// are one click away, and where a board for one is made (#119). |
|
| 57 | 58 | orgs: Vec<String>, |
|
| 59 | + | /// The title filter, and whether the closed ones are shown (#124). |
|
| 60 | + | q: String, |
|
| 61 | + | closed: bool, |
|
| 62 | + | open_count: usize, |
|
| 63 | + | closed_count: usize, |
|
| 58 | 64 | } |
|
| 59 | 65 | ||
| 66 | + | impl ProjectsPage { |
|
| 67 | + | /// The page for `all` the projects the viewer may see: counted open |
|
| 68 | + | /// and closed among those matching `q`, then the ones asked for. |
|
| 69 | + | #[allow(clippy::too_many_arguments)] |
|
| 70 | + | fn build( |
|
| 71 | + | shell: Shell, |
|
| 72 | + | owner: String, |
|
| 73 | + | all: Vec<pdb::Project>, |
|
| 74 | + | can_edit: bool, |
|
| 75 | + | error: Option<String>, |
|
| 76 | + | orgs: Vec<String>, |
|
| 77 | + | q: Option<String>, |
|
| 78 | + | closed: bool, |
|
| 79 | + | ) -> ProjectsPage { |
|
| 80 | + | let q = q.unwrap_or_default().trim().to_string(); |
|
| 81 | + | let needle = q.to_lowercase(); |
|
| 82 | + | let matching: Vec<pdb::Project> = all |
|
| 83 | + | .into_iter() |
|
| 84 | + | .filter(|p| needle.is_empty() || p.title.to_lowercase().contains(&needle) || p.description.to_lowercase().contains(&needle)) |
|
| 85 | + | .collect(); |
|
| 86 | + | let closed_count = matching.iter().filter(|p| p.closed).count(); |
|
| 87 | + | let open_count = matching.len() - closed_count; |
|
| 88 | + | let projects = matching.into_iter().filter(|p| p.closed == closed).collect(); |
|
| 89 | + | ProjectsPage { shell, owner, projects, can_edit, error, orgs, q, closed, open_count, closed_count } |
|
| 90 | + | } |
|
| 91 | + | } |
|
| 92 | + | ||
| 93 | + | /// `?q=TEXT&state=open|closed` on the Projects page (#124). |
|
| 94 | + | #[derive(Deserialize, Default)] |
|
| 95 | + | pub struct ProjectsQuery { |
|
| 96 | + | #[serde(default)] |
|
| 97 | + | q: Option<String>, |
|
| 98 | + | #[serde(default)] |
|
| 99 | + | state: Option<String>, |
|
| 100 | + | } |
|
| 101 | + | ||
| 60 | 102 | /// The organisations the viewer belongs to, when `owner` is the viewer. |
|
| 61 | 103 | async fn own_orgs(app: &Shared, viewer: &Viewer, owner: &str) -> Vec<String> { |
|
| 62 | 104 | match viewer.account() { |
|
| … | 4 unchanged lines |
||
| 67 | 109 | } |
|
| 68 | 110 | } |
|
| 69 | 111 | ||
| 70 | - | pub async fn list(State(app): State<Shared>, viewer: Viewer, Path(owner): Path<String>) -> Result<Page<ProjectsPage>, Error> { |
|
| 112 | + | pub async fn list( |
|
| 113 | + | State(app): State<Shared>, |
|
| 114 | + | viewer: Viewer, |
|
| 115 | + | Path(owner): Path<String>, |
|
| 116 | + | Query(query): Query<ProjectsQuery>, |
|
| 117 | + | ) -> Result<Page<ProjectsPage>, Error> { |
|
| 71 | 118 | let shell = Shell::new(&app, &format!("/projects/{owner}"), &viewer); |
|
| 72 | 119 | let r = rights(&app, &shell, &viewer, &owner).await?; |
|
| 73 | 120 | let projects = pdb::of_owner(&app.db, &owner) |
|
| … | 3 unchanged lines |
||
| 77 | 124 | .filter(|p| p.public || r.member) |
|
| 78 | 125 | .collect(); |
|
| 79 | 126 | let orgs = own_orgs(&app, &viewer, &owner).await; |
|
| 80 | - | Ok(Page(ProjectsPage { shell, owner, projects, can_edit: r.edit, error: None, orgs })) |
|
| 127 | + | let closed = query.state.as_deref() == Some("closed"); |
|
| 128 | + | Ok(Page(ProjectsPage::build(shell, owner, projects, r.edit, None, orgs, query.q, closed))) |
|
| 81 | 129 | } |
|
| 82 | 130 | ||
| 83 | 131 | #[derive(Deserialize)] |
|
| … | 23 unchanged lines |
||
| 107 | 155 | if title.is_empty() || title.len() > 100 { |
|
| 108 | 156 | let projects = pdb::of_owner(&app.db, &owner).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 109 | 157 | let orgs = own_orgs(&app, &viewer, &owner).await; |
|
| 110 | - | return Ok(Page(ProjectsPage { |
|
| 158 | + | return Ok(Page(ProjectsPage::build( |
|
| 111 | 159 | shell, |
|
| 112 | 160 | owner, |
|
| 113 | 161 | projects, |
|
| 114 | - | can_edit: true, |
|
| 115 | - | error: Some("A project needs a title of up to 100 characters.".into()), |
|
| 162 | + | true, |
|
| 163 | + | Some("A project needs a title of up to 100 characters.".into()), |
|
| 116 | 164 | orgs, |
|
| 117 | - | }) |
|
| 165 | + | None, |
|
| 166 | + | false, |
|
| 167 | + | )) |
|
| 118 | 168 | .into_response()); |
|
| 119 | 169 | } |
|
| 120 | 170 | let p = pdb::create(&app.db, &owner, title, f.description.trim(), f.private.is_none()) |
|
| … | 458 unchanged lines |
modified crates/hub/tests/issues.rs +12 −0
| … | 764 unchanged lines |
||
| 765 | 765 | let csrf = csrf_of(&list); |
|
| 766 | 766 | let r = post(app, "/projects/andy", Some(&andy), &format!("_csrf={csrf}&title=Roadmap&description=Next+steps")).await; |
|
| 767 | 767 | assert_eq!(r.location.as_deref(), Some("/projects/andy/1"), "{}", r.body); |
|
| 768 | + | // The list (#124): a row per project, counted under Open, and a filter. |
|
| 769 | + | let list = get(app, "/projects/andy", Some(&andy)).await.body; |
|
| 770 | + | assert!( |
|
| 771 | + | list.contains(">Roadmap</a>") && list.contains("Next steps") && list.contains("Open <span class=\"count-chip\">1</span>"), |
|
| 772 | + | "{list}" |
|
| 773 | + | ); |
|
| 774 | + | let filtered = get(app, "/projects/andy?q=roadm", Some(&andy)).await.body; |
|
| 775 | + | assert!(filtered.contains(">Roadmap</a>"), "a filter matches part of the title, any case"); |
|
| 776 | + | let none = get(app, "/projects/andy?q=nothing+like+it", Some(&andy)).await.body; |
|
| 777 | + | assert!(none.contains("No open project matches") && !none.contains(">Roadmap</a>"), "{none}"); |
|
| 778 | + | let closed = get(app, "/projects/andy?state=closed", Some(&andy)).await.body; |
|
| 779 | + | assert!(closed.contains("No closed projects.") && closed.contains("Closed <span class=\"count-chip\">0</span>"), "{closed}"); |
|
| 768 | 780 | let board = get(app, "/projects/andy/1", Some(&andy)).await.body; |
|
| 769 | 781 | for col in ["Backlog", "Doing", "Done"] { |
|
| 770 | 782 | assert!(board.contains(&format!(">{col} <span")), "{col}"); |
|
| … | 83 unchanged lines |
modified crates/hub/tests/storage.rs +2 −2
| … | 119 unchanged lines |
||
| 120 | 120 | let (st, body) = new_repo(&h.app, &me, "name=local").await; |
|
| 121 | 121 | assert_eq!(st, StatusCode::SEE_OTHER, "{body}"); |
|
| 122 | 122 | let page = send(&h.app, "GET", "/andy/local", Some(&me), None).await.1; |
|
| 123 | - | assert!(!page.contains(">bucket</span>")); |
|
| 123 | + | assert!(!page.contains("class=\"store store-"), "no store icon on a repository on this disk"); |
|
| 124 | 124 | } |
|
| 125 | 125 | ||
| 126 | 126 | /// The S3 test bucket's URL with only its region kept, and the profile's keys. |
|
| … | 69 unchanged lines |
||
| 196 | 196 | } |
|
| 197 | 197 | // The pages say where it lives; Settings → Storage checks the bucket. |
|
| 198 | 198 | let page = send(&h.app, "GET", "/andy/cloudy", Some(&me), None).await.1; |
|
| 199 | - | assert!(page.contains(">bucket</span>") && page.contains(url.split('?').next().unwrap()), "{page}"); |
|
| 199 | + | assert!(page.contains("class=\"store store-s3\"") && page.contains(url.split('?').next().unwrap()), "{page}"); |
|
| 200 | 200 | let settings = send(&h.app, "GET", "/andy/cloudy/settings", Some(&me), None).await.1; |
|
| 201 | 201 | assert!(settings.contains("id=\"storage\""), "{settings}"); |
|
| 202 | 202 | let csrf = csrf_of(&settings); |
|
| … | 59 unchanged lines |
modified templates/projects.html +36 −12
| 1 | 1 | {% extends "base.html" %} |
|
| 2 | 2 | {% block title %}Projects · {{ owner }}{% endblock %} |
|
| 3 | 3 | {% block content %} |
|
| 4 | - | <div class="head"><h1>Projects <small><a href="/{{ owner }}">{{ owner }}</a></small></h1>{% if can_edit %}<a class="go" href="#new">+ NEW PROJECT</a>{% endif %}</div> |
|
| 5 | - | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| 4 | + | <div class="head proj-head"> |
|
| 5 | + | <h1>Projects <small>boards of issues, proposals and notes from <a href="/{{ owner }}">{{ owner }}</a>'s repositories</small></h1> |
|
| 6 | + | </div> |
|
| 6 | 7 | {% if !orgs.is_empty() %}<p class="small mute">Your organisations' projects: {% for o in orgs %}<a href="/projects/{{ o }}">{{ o }}</a>{% if !loop.last %} · {% endif %}{% endfor %}</p>{% endif %} |
|
| 7 | - | {% if projects.is_empty() %}<p class="empty">No projects yet.{% if can_edit %} A project is a board of issues, proposals and notes from {{ owner }}'s repositories: make one below.{% else %} Only {{ owner }}{% if shell.user.is_some() %} (or its members){% endif %} can make one.{% endif %}</p>{% else %} |
|
| 8 | - | <ul class="rows"> |
|
| 9 | - | {% for p in projects %} |
|
| 10 | - | <li><div><a class="name" href="/projects/{{ owner }}/{{ p.number }}">{{ p.title }}</a>{% if !p.public %} <span class="tag">private</span>{% endif %}{% if p.closed %} <span class="tag dim">closed</span>{% endif %} |
|
| 11 | - | <p class="small mute">#{{ p.number }}{% if !p.description.is_empty() %} · {{ p.description }}{% endif %} · updated <time title="{{ p.updated_ms|when_i }}">{{ p.updated_ms|ago_i }}</time></p></div></li> |
|
| 12 | - | {% endfor %} |
|
| 13 | - | </ul> |
|
| 14 | - | {% endif %} |
|
| 8 | + | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| 9 | + | <div class="proj-bar"> |
|
| 10 | + | <form class="proj-filter" method="get" action="/projects/{{ owner }}" role="search"> |
|
| 11 | + | <svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" focusable="false"><circle cx="6.8" cy="6.8" r="4.6" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="m10.3 10.3 4 4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg> |
|
| 12 | + | <input type="search" id="q" name="q" value="{{ q }}" placeholder="Filter projects by title or description" aria-label="Filter projects"> |
|
| 13 | + | {% if closed %}<input type="hidden" name="state" value="closed">{% endif %} |
|
| 14 | + | </form> |
|
| 15 | + | {% if can_edit %}<a class="go" href="#new">+ NEW PROJECT</a>{% endif %} |
|
| 16 | + | </div> |
|
| 15 | 17 | {% if can_edit %} |
|
| 16 | - | <form class="card" id="new" method="post" action="/projects/{{ owner }}"> |
|
| 18 | + | <form class="card proj-new{% if error.is_some() %} open{% endif %}" id="new" method="post" action="/projects/{{ owner }}"> |
|
| 17 | 19 | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 18 | 20 | <h2>New project</h2> |
|
| 19 | 21 | <label for="title">Title</label><input type="text" id="title" name="title" maxlength="100" required> |
|
| 20 | 22 | <label for="description">Description</label><input type="text" id="description" name="description"> |
|
| 21 | 23 | <div class="choice"><label><input type="checkbox" name="private" value="1"> private (only {% if owner == shell.user.clone().unwrap_or_default() %}you{% else %}members of {{ owner }}{% endif %})</label></div> |
|
| 22 | - | <button type="submit">CREATE</button> |
|
| 24 | + | <div class="proj-new-actions"><button type="submit">CREATE</button> <a href="/projects/{{ owner }}">Cancel</a></div> |
|
| 23 | 25 | <p class="small mute">It starts with the columns Backlog, Doing and Done.</p> |
|
| 24 | 26 | </form> |
|
| 25 | 27 | {% endif %} |
|
| 28 | + | <div class="proj-list"> |
|
| 29 | + | <div class="proj-tabs" role="tablist"> |
|
| 30 | + | <a role="tab" href="/projects/{{ owner }}{% if !q.is_empty() %}?q={{ q|urlencode }}{% endif %}"{% if !closed %} class="on" aria-selected="true"{% endif %}><svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" focusable="false"><rect x="1.75" y="2.25" width="12.5" height="11.5" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M6.25 2.5v11M10.25 2.5v11" stroke="currentColor" stroke-width="1.5"/></svg> Open <span class="count-chip">{{ open_count }}</span></a> |
|
| 31 | + | <a role="tab" href="/projects/{{ owner }}?state=closed{% if !q.is_empty() %}&q={{ q|urlencode }}{% endif %}"{% if closed %} class="on" aria-selected="true"{% endif %}><svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" focusable="false"><path d="m3 8.5 3 3 7-7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg> Closed <span class="count-chip">{{ closed_count }}</span></a> |
|
| 32 | + | </div> |
|
| 33 | + | {% if projects.is_empty() %} |
|
| 34 | + | <p class="empty">{% if !q.is_empty() %}No {% if closed %}closed{% else %}open{% endif %} project matches “{{ q }}”.{% else if closed %}No closed projects.{% else if open_count + closed_count == 0 %}No projects yet.{% if can_edit %} A project is a board of issues, proposals and notes from {{ owner }}'s repositories: <a href="#new">make one</a>.{% else %} Only {{ owner }}{% if shell.user.is_some() %} (or its members){% endif %} can make one.{% endif %}{% else %}No open projects.{% endif %}</p> |
|
| 35 | + | {% else %} |
|
| 36 | + | <ul class="proj-rows"> |
|
| 37 | + | {% for p in projects %} |
|
| 38 | + | <li> |
|
| 39 | + | <svg class="proj-icon" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" focusable="false"><rect x="1.75" y="2.25" width="12.5" height="11.5" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M6.25 2.5v11M10.25 2.5v11" stroke="currentColor" stroke-width="1.5"/></svg> |
|
| 40 | + | <div class="proj-main"> |
|
| 41 | + | <div class="proj-title"><a class="name" href="/projects/{{ owner }}/{{ p.number }}">{{ p.title }}</a>{% if !p.public %} <span class="vis vis-private">private</span>{% endif %}{% if p.closed %} <span class="tag dim">closed</span>{% endif %}</div> |
|
| 42 | + | <p class="small mute">#{{ p.number }} · updated <time title="{{ p.updated_ms|when_i }}">{{ p.updated_ms|ago_i }}</time></p> |
|
| 43 | + | {% if !p.description.is_empty() %}<p class="proj-desc">{{ p.description }}</p>{% endif %} |
|
| 44 | + | </div> |
|
| 45 | + | </li> |
|
| 46 | + | {% endfor %} |
|
| 47 | + | </ul> |
|
| 48 | + | {% endif %} |
|
| 49 | + | </div> |
|
| 26 | 50 | {% endblock %} |
modified templates/repo_tabs.html +1 −1
| 1 | 1 | {% macro tabs(repo, tab) %} |
|
| 2 | 2 | <nav class="tabs" aria-label="Repository"> |
|
| 3 | 3 | <div class="tabs-in"> |
|
| 4 | - | <span class="crumb"><a href="/{{ repo.owner }}">{{ repo.owner }}</a> / <a href="{{ repo.url }}"><strong>{{ repo.name }}</strong></a>{% if repo.archived %} <span class="tag warn" title="This repository is archived: read-only">archived</span>{% endif %} <span class="vis vis-{{ repo.visibility }}" title="{% if repo.visibility == "public" %}Anyone can see this repository{% else if repo.visibility == "internal" %}Anyone signed in to this hub can see this repository{% else %}Only people given access can see this repository{% endif %}"><span aria-hidden="true">{% if repo.visibility == "public" %}◯{% else if repo.visibility == "internal" %}◐{% else %}●{% endif %}</span> {{ repo.visibility }}</span>{% if let Some(b) = repo.stored_in %} <span class="tag" title="This repository's data lives in {% if repo.can(crate::auth::Access::Admin) %}{{ b }}, {% endif %}its owner's bucket">bucket</span>{% endif %}</span> |
|
| 4 | + | <span class="crumb"><a href="/{{ repo.owner }}">{{ repo.owner }}</a> / <a href="{{ repo.url }}"><strong>{{ repo.name }}</strong></a>{% if repo.archived %} <span class="tag warn" title="This repository is archived: read-only">archived</span>{% endif %} <span class="vis vis-{{ repo.visibility }}" title="{% if repo.visibility == "public" %}Anyone can see this repository{% else if repo.visibility == "internal" %}Anyone signed in to this hub can see this repository{% else %}Only people given access can see this repository{% endif %}"><span aria-hidden="true">{% if repo.visibility == "public" %}◯{% else if repo.visibility == "internal" %}◐{% else %}●{% endif %}</span> {{ repo.visibility }}</span>{% if let Some(b) = repo.stored_in %} <span class="store store-{{ b.kind }}" tabindex="0" aria-label="Kept in its owner's {{ b.provider }}"><svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" focusable="false">{% if b.kind == "gcs" %}<path d="M4.2 1.5h7.6L15.6 8l-3.8 6.5H4.2L.4 8z" fill="#4285f4"/><rect x="4.6" y="5" width="6.8" height="2" rx=".6" fill="#fff"/><rect x="4.6" y="9" width="6.8" height="2" rx=".6" fill="#fff"/>{% else if b.kind == "s3" %}<path d="M1.8 3.6 3.6 13.2c.2 1 2.2 1.6 4.4 1.6s4.2-.6 4.4-1.6l1.8-9.6z" fill="#7aa116"/><ellipse cx="8" cy="3.6" rx="6.2" ry="2.1" fill="#a4cc4c"/>{% else %}<rect x="1.5" y="2" width="13" height="12" rx="1.6" fill="#0078d4"/><rect x="4" y="5" width="8" height="1.6" fill="#fff"/><rect x="4" y="7.7" width="8" height="1.6" fill="#fff"/><rect x="4" y="10.4" width="5" height="1.6" fill="#fff"/>{% endif %}</svg><span class="store-card" role="tooltip"><strong>{{ b.provider }}</strong>{% if repo.can(crate::auth::Access::Admin) %}<dl><dt>{% if b.kind == "azure" %}Account / container{% else %}Bucket{% endif %}</dt><dd><code>{{ b.place }}</code></dd>{% if !b.prefix.is_empty() %}<dt>Prefix</dt><dd><code>{{ b.prefix }}</code></dd>{% endif %}{% if let Some(r) = b.region %}<dt>Region</dt><dd>{{ r }}</dd>{% endif %}<dt>URL</dt><dd><code>{{ b.url }}</code></dd></dl><a href="{{ repo.url }}/settings#storage">Storage settings and the last check</a>{% else %}<span class="mute">This repository's data is kept in its owner's own {{ b.provider }}, not on this server's disk.</span>{% endif %}</span></span>{% endif %}</span> |
|
| 5 | 5 | <a href="{{ repo.url }}"{% if tab == "code" %} class="on" aria-current="page"{% endif %}>Code</a> |
|
| 6 | 6 | <a href="{{ repo.url }}/changes/{{ repo.default_bookmark }}"{% if tab == "changes" %} class="on" aria-current="page"{% endif %}>Changes</a> |
|
| 7 | 7 | <a href="{{ repo.url }}/proposals"{% if tab == "proposals" %} class="on" aria-current="page"{% endif %}>Proposals{% if repo.open_proposals > 0 %} <span class="count-chip">{{ repo.open_proposals }}</span>{% endif %}</a> |
|
| … | 70 unchanged lines |
modified templates/style.css +43 −0
| … | 856 unchanged lines |
||
| 857 | 857 | .cov-code tr.cov-hit td.t, .cov-key.cov-hit { background: color-mix(in srgb, var(--ok) 16%, transparent); } |
|
| 858 | 858 | .cov-code tr.cov-miss td.t, .cov-key.cov-miss { background: color-mix(in srgb, var(--bad) 16%, transparent); } |
|
| 859 | 859 | .cov-code td.cov-hits { text-align: right; padding: 0 .5em; color: var(--mute); font-size: .85em; white-space: nowrap; user-select: none; } |
|
| 860 | + | ||
| 861 | + | /* Where a repository's data lives, when it is its owner's bucket (#123): |
|
| 862 | + | the provider's icon, and a card with the details on hover, focus or a |
|
| 863 | + | tap (it takes focus). */ |
|
| 864 | + | .store { position: relative; display: inline-flex; align-items: center; margin-left: 8px; vertical-align: middle; cursor: pointer; border-radius: 3px; } |
|
| 865 | + | .store svg { display: block; } |
|
| 866 | + | .store:focus-visible { outline: 2px solid var(--acc); outline-offset: 2px; } |
|
| 867 | + | .store-card { display: none; position: absolute; top: calc(100% + 8px); left: -6px; z-index: 30; width: max-content; max-width: min(420px, 90vw); background: var(--panel); border: 1px solid var(--line); box-shadow: 0 8px 24px rgba(0, 0, 0, .28); padding: 10px 12px; font-size: 13px; font-weight: 400; line-height: 1.45; white-space: normal; color: var(--ink); cursor: auto; } |
|
| 868 | + | .store-card::before { content: ""; position: absolute; top: -10px; left: 0; right: 0; height: 10px; } |
|
| 869 | + | .store:hover .store-card, .store:focus .store-card, .store:focus-within .store-card { display: block; } |
|
| 870 | + | .store-card strong { display: block; margin-bottom: 4px; } |
|
| 871 | + | .store-card dl { display: grid; grid-template-columns: auto 1fr; gap: 3px 12px; margin: 6px 0 8px; } |
|
| 872 | + | .store-card dt { color: var(--mute); } |
|
| 873 | + | .store-card dd { margin: 0; min-width: 0; } |
|
| 874 | + | .store-card code { word-break: break-all; } |
|
| 875 | + | ||
| 876 | + | /* Projects (#124): a filter and a New project button above a panel of |
|
| 877 | + | rows with Open and Closed tabs; the form opens from the button. */ |
|
| 878 | + | .proj-bar { display: flex; align-items: center; gap: 10px; margin: 0 0 14px; } |
|
| 879 | + | .proj-filter { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; margin: 0; padding: 0 10px; border: 1px solid var(--line); background: var(--panel); color: var(--mute); } |
|
| 880 | + | .proj-filter:focus-within { border-color: var(--acc); } |
|
| 881 | + | .proj-filter input { flex: 1; min-width: 0; width: auto; margin: 0; padding: 7px 0; border: 0; background: transparent; color: var(--ink); outline: none; } |
|
| 882 | + | .proj-new { display: none; max-width: 560px; margin: 0 0 16px; } |
|
| 883 | + | .proj-new:target, .proj-new.open { display: block; } |
|
| 884 | + | .proj-new h2 { margin-top: 0; } |
|
| 885 | + | .proj-new-actions { display: flex; align-items: center; gap: 14px; margin-top: 4px; } |
|
| 886 | + | .proj-list { border: 1px solid var(--line); background: var(--panel); } |
|
| 887 | + | .proj-tabs { display: flex; gap: 4px; padding: 8px 12px; background: var(--soft); border-bottom: 1px solid var(--line); } |
|
| 888 | + | .proj-tabs a { display: inline-flex; align-items: center; gap: 6px; padding: 4px 8px; color: var(--mute); font-size: 14px; } |
|
| 889 | + | .proj-tabs a:hover { color: var(--ink); text-decoration: none; } |
|
| 890 | + | .proj-tabs a.on { color: var(--ink); font-weight: 600; } |
|
| 891 | + | .proj-rows { list-style: none; margin: 0; padding: 0; } |
|
| 892 | + | .proj-rows li { display: flex; align-items: flex-start; gap: 12px; padding: 12px 16px; border-bottom: 1px solid var(--line); } |
|
| 893 | + | .proj-rows li:last-child { border-bottom: 0; } |
|
| 894 | + | .proj-rows li:hover { background: var(--soft); } |
|
| 895 | + | .proj-icon { flex: none; margin-top: 3px; color: var(--mute); } |
|
| 896 | + | .proj-main { flex: 1; min-width: 0; } |
|
| 897 | + | .proj-main p { margin: 2px 0 0; } |
|
| 898 | + | .proj-title { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; } |
|
| 899 | + | .proj-title .name { font-size: 16px; font-weight: 600; } |
|
| 900 | + | .proj-title .vis { margin-left: 2px; } |
|
| 901 | + | .proj-desc { color: var(--ink); font-size: 14px; } |
|
| 902 | + | .proj-list .empty { border: 0; margin: 0; } |