OXYGENHUB

Stars, topics, and a sortable, filterable Explore 1553bfc9ae53

Anyone signed in can star a repository; the overview shows star and
watcher counts. Maintainers set topics under settings (letters, digits
and dashes, at most 20); they show as chips linking to Explore.
Explore filters by text (name, description, topic) and by topic, and
sorts by recent activity, stars or name, with each row's topics and
stars. Migration 0010 adds stars and topics.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-28 09:00 UTC
Signature
✔ signed by Andy McCall
Hash
1553bfc9ae537a50984bff8b259f502f09e54e871e437e253869ea5e334b54f8
Depends on
07b1a5bbf481 101740454617 145306044896 18e381ea88c2 19bb31ff1f5c 25ab8c73514a 3f4b0ea6eabf 4edcf24025cf 93be9ce4653e d23464c0ac60
Depended on by
04f9c984439e 351cf57e5608 c7067c7bda56 5d90e0e045e2 bff9106edcba 0a3b244b748f 7f283f8e8b97 c9aacc0e7dc1 f677d6270298 d1727c573f30 9008bf6c2b8c 78001759604c a160b98fb922 1ac84c62ba53
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.1.0 v0.2.0 v0.3.0 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
32 atoms, 1 tree operation, 13 files

Browse the tree at this change

added crates/hub/migrations/0010_stars_topics.sql +10 −0
1 +
-- Stars and topics (hub spec §6: explore).
2 +
CREATE TABLE stars (
3 +
    account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
4 +
    repo_id    INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
5 +
    at_ms      INTEGER NOT NULL,
6 +
    PRIMARY KEY (account_id, repo_id)
7 +
);
8 +
CREATE INDEX stars_by_repo ON stars (repo_id);
9 +
-- Space-separated, lower case.
10 +
ALTER TABLE repositories ADD COLUMN topics TEXT NOT NULL DEFAULT '';
modified crates/hub/src/db/repos.rs +83 −0
…
20 unchanged lines
21 21
    /// The repository this one was forked from (migration 0009).
22 22
    #[sqlx(default)]
23 23
    pub forked_from: Option<i64>,
24 +
    /// Space-separated topics (migration 0010).
25 +
    #[sqlx(default)]
26 +
    pub topics: String,
24 27
}
25 28
26 29
impl RepoRow {
…
1 unchanged lines
28 31
        Visibility::parse(&self.visibility).unwrap_or(Visibility::Private)
29 32
    }
30 33
34 +
    pub fn topic_list(&self) -> Vec<&str> {
35 +
        self.topics.split_whitespace().collect()
36 +
    }
37 +
31 38
    pub fn slug(&self) -> String {
32 39
        format!("{}/{}", self.owner, self.name)
33 40
    }
…
110 unchanged lines
144 151
        .await
145 152
}
146 153
154 +
/// Topics from user input: comma or space separated, lower-cased,
155 +
/// letters, digits and dashes, at most 35 characters each and 20 in all.
156 +
/// Returns the stored form or what is wrong.
157 +
pub fn parse_topics(input: &str) -> Result<String, String> {
158 +
    let mut out: Vec<String> = Vec::new();
159 +
    for t in input.split([',', ' ', '\n', '\t']).map(|t| t.trim().to_ascii_lowercase()).filter(|t| !t.is_empty()) {
160 +
        if t.len() > 35 || !t.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') || t.starts_with('-') {
161 +
            return Err(format!("\"{t}\" is not a topic: use letters, digits and dashes, up to 35 characters"));
162 +
        }
163 +
        if !out.contains(&t) {
164 +
            out.push(t);
165 +
        }
166 +
    }
167 +
    if out.len() > 20 {
168 +
        return Err("At most 20 topics.".into());
169 +
    }
170 +
    Ok(out.join(" "))
171 +
}
172 +
173 +
pub async fn set_topics(pool: &Pool, id: i64, topics: &str) -> sqlx::Result<()> {
174 +
    sqlx::query("UPDATE repositories SET topics = ? WHERE id = ?").bind(topics).bind(id).execute(pool).await?;
175 +
    Ok(())
176 +
}
177 +
178 +
/// Star or unstar; returns whether it is now starred.
179 +
pub async fn toggle_star(pool: &Pool, account_id: i64, repo_id: i64) -> sqlx::Result<bool> {
180 +
    let gone = sqlx::query("DELETE FROM stars WHERE account_id = ? AND repo_id = ?").bind(account_id).bind(repo_id).execute(pool).await?;
181 +
    if gone.rows_affected() > 0 {
182 +
        return Ok(false);
183 +
    }
184 +
    sqlx::query("INSERT INTO stars (account_id, repo_id, at_ms) VALUES (?, ?, ?)")
185 +
        .bind(account_id)
186 +
        .bind(repo_id)
187 +
        .bind(now_ms())
188 +
        .execute(pool)
189 +
        .await?;
190 +
    Ok(true)
191 +
}
192 +
193 +
pub async fn starred(pool: &Pool, account_id: i64, repo_id: i64) -> sqlx::Result<bool> {
194 +
    let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM stars WHERE account_id = ? AND repo_id = ?")
195 +
        .bind(account_id)
196 +
        .bind(repo_id)
197 +
        .fetch_one(pool)
198 +
        .await?;
199 +
    Ok(n > 0)
200 +
}
201 +
202 +
/// Stars and watchers of one repository.
203 +
pub async fn counts(pool: &Pool, repo_id: i64) -> sqlx::Result<(i64, i64)> {
204 +
    let stars: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM stars WHERE repo_id = ?").bind(repo_id).fetch_one(pool).await?;
205 +
    let watchers: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM watches WHERE repo_id = ?").bind(repo_id).fetch_one(pool).await?;
206 +
    Ok((stars, watchers))
207 +
}
208 +
209 +
/// Star counts of every starred repository.
210 +
pub async fn star_counts(pool: &Pool) -> sqlx::Result<std::collections::HashMap<i64, i64>> {
211 +
    let rows: Vec<(i64, i64)> = sqlx::query_as("SELECT repo_id, COUNT(*) FROM stars GROUP BY repo_id").fetch_all(pool).await?;
212 +
    Ok(rows.into_iter().collect())
213 +
}
214 +
147 215
/// Record that `id` is a fork of `from`.
148 216
pub async fn set_forked_from(pool: &Pool, id: i64, from: i64) -> sqlx::Result<()> {
149 217
    sqlx::query("UPDATE repositories SET forked_from = ? WHERE id = ?").bind(from).bind(id).execute(pool).await?;
…
14 unchanged lines
164 232
    sqlx::query("UPDATE repositories SET git_mirror_state = ? WHERE id = ?").bind(state).bind(id).execute(pool).await?;
165 233
    Ok(())
166 234
}
235 +
236 +
#[cfg(test)]
237 +
mod topic_tests {
238 +
    use super::parse_topics;
239 +
240 +
    #[test]
241 +
    fn topics_are_normalised_and_checked() {
242 +
        assert_eq!(parse_topics("Rust, vcs  retro-computing,rust").unwrap(), "rust vcs retro-computing");
243 +
        assert_eq!(parse_topics("").unwrap(), "");
244 +
        assert!(parse_topics("c++").is_err());
245 +
        assert!(parse_topics("-lead").is_err());
246 +
        assert!(parse_topics(&"x".repeat(36)).is_err());
247 +
        assert!(parse_topics(&(0..21).map(|i| format!("t{i}")).collect::<Vec<_>>().join(" ")).is_err());
248 +
    }
249 +
}
modified crates/hub/src/web/home.rs +65 −7
…
3 unchanged lines
4 4
use crate::auth::Viewer;
5 5
use crate::{db, Shared};
6 6
use askama::Template;
7 -
use axum::extract::{Path, State};
7 +
use axum::extract::{Path, Query, State};
8 8
use axum::http::header;
9 9
use axum::response::{IntoResponse, Response};
10 10
use hub_api::Visibility;
11 +
use serde::Deserialize;
11 12
12 13
#[derive(Template)]
13 14
#[template(path = "home.html")]
…
2 unchanged lines
16 17
    description: String,
17 18
    repos: Vec<db::repos::RepoRow>,
18 19
    owner: Option<String>,
20 +
    /// Explore's controls: the filter text, the sort, the topic.
21 +
    q: String,
22 +
    sort: String,
23 +
    topic: String,
24 +
    stars: std::collections::HashMap<i64, i64>,
25 +
}
26 +
27 +
impl HomePage {
28 +
    fn star_count(&self, id: &i64) -> i64 {
29 +
        self.stars.get(id).copied().unwrap_or(0)
30 +
    }
31 +
}
32 +
33 +
/// `/explore?q=&sort=activity|stars|name&topic=`.
34 +
#[derive(Deserialize, Default)]
35 +
pub struct ExploreQuery {
36 +
    #[serde(default)]
37 +
    q: String,
38 +
    #[serde(default)]
39 +
    sort: String,
40 +
    #[serde(default)]
41 +
    topic: String,
19 42
}
20 43
21 44
/// The signed-in dashboard (hub spec §6): the review queue, the
…
16 unchanged lines
38 61
/// `/`: the dashboard when signed in, every visible repository otherwise.
39 62
pub async fn index(State(app): State<Shared>, viewer: Viewer) -> Result<Response, Error> {
40 63
    let Some(account) = viewer.account().cloned() else {
41 -
        return Ok(explore(State(app), viewer).await?.into_response());
64 +
        return Ok(explore(State(app), viewer, Query(ExploreQuery::default())).await?.into_response());
42 65
    };
43 66
    let shell = Shell::new(&app, "/", &viewer);
44 67
    let db_err = |e| Error::from_db(shell.clone(), e);
…
10 unchanged lines
55 78
    Ok(Page(Dashboard { shell, mine, reviews, authored, assigned, feed }).into_response())
56 79
}
57 80
58 -
/// `/explore`: every repository the viewer may see.
59 -
pub async fn explore(State(app): State<Shared>, viewer: Viewer) -> Result<Page<HomePage>, Error> {
81 +
/// `/explore`: every repository the viewer may see, filtered by text and
82 +
/// topic, sorted by recent activity (the default), stars or name.
83 +
pub async fn explore(State(app): State<Shared>, viewer: Viewer, Query(q): Query<ExploreQuery>) -> Result<Page<HomePage>, Error> {
60 84
    let shell = Shell::new(&app, "/explore", &viewer);
61 -
    let repos = visible(&app, &shell, &viewer, db::repos::list(&app.db).await.map_err(|e| Error::from_db(shell.clone(), e))?).await?;
62 -
    Ok(Page(HomePage { description: app.config.site.description.clone(), shell, repos, owner: None }))
85 +
    let db_err = |e| Error::from_db(shell.clone(), e);
86 +
    let mut repos = visible(&app, &shell, &viewer, db::repos::list(&app.db).await.map_err(db_err)?).await?;
87 +
    let stars = db::repos::star_counts(&app.db).await.map_err(db_err)?;
88 +
    let needle = q.q.trim().to_lowercase();
89 +
    if !needle.is_empty() {
90 +
        repos.retain(|r| {
91 +
            r.slug().to_lowercase().contains(&needle)
92 +
                || r.description.to_lowercase().contains(&needle)
93 +
                || r.topic_list().iter().any(|t| t.contains(&needle))
94 +
        });
95 +
    }
96 +
    let topic = q.topic.trim().to_lowercase();
97 +
    if !topic.is_empty() {
98 +
        repos.retain(|r| r.topic_list().contains(&topic.as_str()));
99 +
    }
100 +
    let sort = match q.sort.as_str() {
101 +
        "stars" | "name" => q.sort.clone(),
102 +
        _ => "activity".to_string(),
103 +
    };
104 +
    match sort.as_str() {
105 +
        "stars" => repos.sort_by_key(|r| (std::cmp::Reverse(stars.get(&r.id).copied().unwrap_or(0)), std::cmp::Reverse(r.updated_ms))),
106 +
        "name" => repos.sort_by_key(|r| (r.name.to_lowercase(), r.owner.to_lowercase())),
107 +
        _ => repos.sort_by_key(|r| std::cmp::Reverse(r.updated_ms)),
108 +
    }
109 +
    Ok(Page(HomePage { description: app.config.site.description.clone(), shell, repos, owner: None, q: q.q, sort, topic, stars }))
63 110
}
64 111
65 112
/// A person's page (hub spec §6): avatar, names, bio, linked signing
…
72 unchanged lines
138 185
    if repos.is_empty() {
139 186
        return Err(Error::not_found(shell));
140 187
    }
141 -
    Ok(Page(HomePage { description: String::new(), shell, repos, owner: Some(owner) }).into_response())
188 +
    let stars = db::repos::star_counts(&app.db).await.unwrap_or_default();
189 +
    Ok(Page(HomePage {
190 +
        description: String::new(),
191 +
        shell,
192 +
        repos,
193 +
        owner: Some(owner),
194 +
        q: String::new(),
195 +
        sort: "activity".into(),
196 +
        topic: String::new(),
197 +
        stars,
198 +
    })
199 +
    .into_response())
142 200
}
143 201
144 202
/// The repositories among `rows` the viewer may see: public ones, internal
…
41 unchanged lines
modified crates/hub/src/web/issues.rs +15 −0
…
866 unchanged lines
867 867
    Ok(Redirect::to(&format!("/{owner}/{name}")).into_response())
868 868
}
869 869
870 +
/// Star or unstar a repository.
871 +
pub async fn star(
872 +
    State(app): State<Shared>,
873 +
    viewer: Viewer,
874 +
    Path((owner, name)): Path<(String, String)>,
875 +
    Form(f): Form<super::session::CsrfForm>,
876 +
) -> Result<Response, Error> {
877 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}"), &viewer);
878 +
    let me = require(&viewer, &shell)?;
879 +
    csrf(&viewer, &f.csrf, &shell)?;
880 +
    let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
881 +
    db::repos::toggle_star(&app.db, me.account.id, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
882 +
    Ok(Redirect::to(&format!("/{owner}/{name}")).into_response())
883 +
}
884 +
870 885
/// Close issues a landed change's message names (`closes #12`), when the
871 886
/// landing reached the default bookmark. Called from the push hook and
872 887
/// the hub's own landing.
…
24 unchanged lines
modified crates/hub/src/web/mod.rs +1 −0
…
112 unchanged lines
113 113
        .route("/{owner}/{name}/settings/hooks/{id}/redeliver/{delivery}", post(repo_settings::redeliver))
114 114
        .route("/{owner}/{name}/settings/checks", post(repo_settings::required_checks))
115 115
        .route("/{owner}/{name}/watch", post(issues::watch))
116 +
        .route("/{owner}/{name}/star", post(issues::star))
116 117
        .route("/{owner}/{name}/edit/{r}", post(edit::save))
117 118
        .route("/{owner}/{name}/edit/{r}/{*path}", get(edit::edit_form))
118 119
        .route("/{owner}/{name}/newfile/{r}", get(edit::new_form))
…
377 unchanged lines
modified crates/hub/src/web/repo.rs +11 −1
…
21 unchanged lines
22 22
    /// Set while the repository has no changes (hub spec §6).
23 23
    quickstart: Option<Quickstart>,
24 24
    fork: super::forks::ForkInfo,
25 +
    starred: bool,
26 +
    stars: i64,
27 +
    watchers: i64,
28 +
    topics: Vec<String>,
25 29
}
26 30
27 31
/// A remote name from the hub's host: its first label when that is a
…
79 unchanged lines
107 111
    let latest_release = crate::db::releases::latest(&app.db, row.id).await.ok().flatten();
108 112
    let quickstart = (o.changes == 0).then(|| Quickstart::new(&app, rs.can(crate::auth::Access::Write)));
109 113
    let fork = super::forks::info(&app, &shell, &viewer, &row).await;
110 -
    Ok(Page(OverviewPage { shell, repo: rs, o, readme_html, watching, latest_release, quickstart, fork }))
114 +
    let starred = match viewer.account() {
115 +
        Some(a) => crate::db::repos::starred(&app.db, a.id, row.id).await.unwrap_or(false),
116 +
        None => false,
117 +
    };
118 +
    let (stars, watchers) = crate::db::repos::counts(&app.db, row.id).await.unwrap_or((0, 0));
119 +
    let topics = row.topic_list().into_iter().map(String::from).collect();
120 +
    Ok(Page(OverviewPage { shell, repo: rs, o, readme_html, watching, latest_release, quickstart, fork, starred, stars, watchers, topics }))
111 121
}
112 122
113 123
impl view::Overview {
…
295 unchanged lines
modified crates/hub/src/web/repo_settings.rs +7 −0
…
129 unchanged lines
130 130
    description: String,
131 131
    default_bookmark: String,
132 132
    visibility: String,
133 +
    #[serde(default)]
134 +
    topics: String,
133 135
}
134 136
135 137
pub async fn update(
…
6 unchanged lines
142 144
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
143 145
    csrf(&viewer, &f.csrf_token, &shell)?;
144 146
    let e = |e| Error::from_db(shell.clone(), e);
147 +
    let topics = match db::repos::parse_topics(&f.topics) {
148 +
        Ok(t) => t,
149 +
        Err(msg) => return settings_page(&app, shell, row, role, None, Some(msg)).await,
150 +
    };
145 151
    db::repos::set_description(&app.db, row.id, f.description.trim()).await.map_err(e)?;
152 +
    db::repos::set_topics(&app.db, row.id, &topics).await.map_err(e)?;
146 153
    if crate::repos::names::valid_segment(f.default_bookmark.trim()) {
147 154
        db::repos::set_default_bookmark(&app.db, row.id, f.default_bookmark.trim()).await.map_err(e)?;
148 155
    }
…
307 unchanged lines
modified crates/hub/tests/issues.rs +61 −0
…
526 unchanged lines
527 527
    let files = get(app, "/andy/demo/proposal/1?tab=files", Some(&andy)).await;
528 528
    assert!(files.body.contains("feature.txt"), "{}", files.body);
529 529
}
530 +
531 +
#[tokio::test(flavor = "multi_thread")]
532 +
async fn stars_topics_and_explore() {
533 +
    let h = hub().await;
534 +
    let app = &h.app;
535 +
    let andy = register(app, "andy").await;
536 +
    let bob = register(app, "bob").await;
537 +
    for (name, desc) in [("alpha", "An emulator"), ("beta", "A compiler"), ("gamma", "Retro games")] {
538 +
        app.repos.create(&RepoName::parse(&format!("andy/{name}")).unwrap(), None).unwrap();
539 +
        db::repos::create(&app.db, "andy", name, desc, hub_api::Visibility::Public).await.unwrap();
540 +
    }
541 +
542 +
    // Topics through settings; bad ones are refused.
543 +
    let csrf = csrf_of(&get(app, "/andy/gamma/settings", Some(&andy)).await.body);
544 +
    let r = post(
545 +
        app,
546 +
        "/andy/gamma/settings",
547 +
        Some(&andy),
548 +
        &format!("_csrf={csrf}&description=Retro+games&default_bookmark=main&visibility=public&topics=c%2B%2B"),
549 +
    )
550 +
    .await;
551 +
    assert!(r.body.contains("is not a topic"), "{}", r.body);
552 +
    let r = post(
553 +
        app,
554 +
        "/andy/gamma/settings",
555 +
        Some(&andy),
556 +
        &format!("_csrf={csrf}&description=Retro+games&default_bookmark=main&visibility=public&topics=Retro%2C+6502"),
557 +
    )
558 +
    .await;
559 +
    assert!(r.body.contains("Saved"), "{}", r.body);
560 +
    assert_eq!(db::repos::get(&app.db, "andy", "gamma").await.unwrap().unwrap().topics, "retro 6502");
561 +
    let page = get(app, "/andy/gamma", None).await;
562 +
    assert!(page.body.contains(r#"href="/explore?topic=retro""#), "{}", page.body);
563 +
564 +
    // Stars: bob stars beta and gamma, andy stars gamma; toggling unstars.
565 +
    for (who, repo) in [(&bob, "beta"), (&bob, "gamma"), (&andy, "gamma"), (&andy, "alpha")] {
566 +
        let csrf = csrf_of(&get(app, &format!("/andy/{repo}"), Some(who)).await.body);
567 +
        assert_eq!(post(app, &format!("/andy/{repo}/star"), Some(who), &format!("_csrf={csrf}")).await.status, StatusCode::SEE_OTHER);
568 +
    }
569 +
    let csrf = csrf_of(&get(app, "/andy/alpha", Some(&andy)).await.body);
570 +
    post(app, "/andy/alpha/star", Some(&andy), &format!("_csrf={csrf}")).await;
571 +
    assert!(get(app, "/andy/gamma", Some(&bob)).await.body.contains("★ UNSTAR"));
572 +
    assert!(get(app, "/andy/gamma", None).await.body.contains("★ 2"));
573 +
    assert!(get(app, "/andy/alpha", None).await.body.contains("★ 0"), "unstarred again");
574 +
575 +
    // Explore: sort by stars, by name, filter by text and by topic.
576 +
    let order = |b: &str| {
577 +
        ["alpha", "beta", "gamma"]
578 +
            .into_iter()
579 +
            .map(|n| (b.find(&format!("/andy/{n}\"")).unwrap_or(usize::MAX), n))
580 +
            .collect::<std::collections::BTreeMap<_, _>>()
581 +
            .into_values()
582 +
            .collect::<Vec<_>>()
583 +
    };
584 +
    assert_eq!(order(&get(app, "/explore?sort=stars", None).await.body)[..2], ["gamma", "beta"]);
585 +
    assert_eq!(order(&get(app, "/explore?sort=name", None).await.body), ["alpha", "beta", "gamma"]);
586 +
    let filtered = get(app, "/explore?q=compiler", None).await.body;
587 +
    assert!(filtered.contains("/andy/beta\"") && !filtered.contains("/andy/alpha\""), "{filtered}");
588 +
    let by_topic = get(app, "/explore?topic=6502", None).await.body;
589 +
    assert!(by_topic.contains("/andy/gamma\"") && !by_topic.contains("/andy/beta\""), "{by_topic}");
590 +
}
modified docs/spec.md +1 −1
…
225 unchanged lines
226 226
| Path | Page |
227 227
|---|---|
228 228
| `/` | Signed in: dashboard (your repositories, proposals awaiting your review, assigned issues, recent activity). Anonymous: explore. |
229 -
| `/explore` | Public repositories, sorted by activity; language and topic filters |
229 +
| `/explore` | Every repository the viewer may see; `q` filters by name, description or topic, `topic` by one topic, `sort` is `activity` (default), `stars` or `name`; each row shows its topics and stars. Topics are set under repository settings (letters, digits and dashes, at most 20); anyone signed in can star a repository, and the overview shows star and watcher counts |
230 230
| `/{owner}` | A person: identicon, display name, bio, location, website, linked signing keys, organisations, repositories the viewer can see, and their recent activity (never the email). An organisation: identicon, description, members with roles, teams with sizes, repositories, recent activity in them. `/avatar/{name}` serves the identicon (SVG, a mirrored 5×5 pattern and hue from BLAKE3 of the lower-cased name). |
231 231
| `/{owner}/{name}` | Repository overview (§6.4); while empty, a quickstart with the exact commands for this hub (remote, certificate, token, push; start from nothing; import from Git) |
232 232
| `/{owner}/{name}/fork`, `/{owner}/{name}/forks` | Fork under an owner the viewer may create repositories for (same visibility, own repository id, objects hard-linked, bookmarks copied, policy not copied); the list of forks the viewer can see. The overview shows "forked from", the fork count, and on a fork a link that opens a proposal upstream with `owner/name:bookmark` as its source |
…
317 unchanged lines
modified templates/home.html +14 −1
…
4 unchanged lines
5 5
  {% if let Some(o) = owner %}<h1>{{ o }} <small>{{ repos.len()|plural("repository", "repositories") }}</small></h1>
6 6
  {% else %}<h1>Repositories <small>{{ description }}</small></h1>{% endif %}
7 7
</div>
8 +
{% if owner.is_none() %}
9 +
<form class="bar explore-bar" method="get" action="/explore">
10 +
  <input type="search" name="q" value="{{ q }}" placeholder="Filter by name, description or topic" aria-label="Filter repositories">
11 +
  <select name="sort" aria-label="Sort">
12 +
    <option value="activity"{% if sort == "activity" %} selected{% endif %}>recently active</option>
13 +
    <option value="stars"{% if sort == "stars" %} selected{% endif %}>most stars</option>
14 +
    <option value="name"{% if sort == "name" %} selected{% endif %}>name</option>
15 +
  </select>
16 +
  {% if !topic.is_empty() %}<input type="hidden" name="topic" value="{{ topic }}"><span class="topic">{{ topic }} <a href="/explore?sort={{ sort }}&amp;q={{ q }}" aria-label="Clear the topic">×</a></span>{% endif %}
17 +
  <button type="submit" class="small">FILTER</button>
18 +
</form>
19 +
{% endif %}
8 20
{% if repos.is_empty() %}
9 21
<p class="empty">No repositories yet. Create one with <code>oxygenhub admin repo create OWNER/NAME</code>, then <code>oxy push</code> into it.</p>
10 22
{% else %}
…
4 unchanged lines
15 27
      <a class="name" href="/{{ r.owner }}/{{ r.name }}"><span class="mute">{{ r.owner }} /</span> <strong>{{ r.name }}</strong></a>
16 28
      {% if r.visibility != "public" %}<span class="tag">{{ r.visibility }}</span>{% endif %}
17 29
      {% if !r.description.is_empty() %}<p class="small">{{ r.description }}</p>{% endif %}
30 +
      {% if !r.topics.is_empty() %}<p class="topics">{% for t in r.topic_list() %}<a class="topic" href="/explore?topic={{ t }}">{{ t }}</a>{% endfor %}</p>{% endif %}
18 31
    </div>
19 -
    <div class="right small">updated <time title="{{ r.updated_ms|when_i }}">{{ r.updated_ms|ago_i }}</time></div>
32 +
    <div class="right small">{% if star_count(r.id) > 0 %}<span class="mute" title="stars">★ {{ star_count(r.id) }}</span> · {% endif %}updated <time title="{{ r.updated_ms|when_i }}">{{ r.updated_ms|ago_i }}</time></div>
20 33
  </li>
21 34
{% endfor %}
22 35
</ul>
…
2 unchanged lines
modified templates/overview.html +4 −1
…
29 unchanged lines
30 30
    {% if repo.description.is_empty() %}<p class="mute small">No description.</p>{% else %}<p>{{ repo.description }}</p>{% endif %}
31 31
    {% if let Some(up) = fork.upstream %}<p class="small">forked from <a href="/{{ up.owner }}/{{ up.name }}">{{ up.owner }}/{{ up.name }}</a></p>{% endif %}
32 32
    <div class="bar">
33 -
    {% if shell.user.is_some() %}<form class="inline" method="post" action="{{ repo.url }}/watch"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit" class="small">{% if watching %}UNWATCH{% else %}WATCH{% endif %}</button></form>
33 +
    {% if !topics.is_empty() %}<p class="topics">{% for t in topics %}<a class="topic" href="/explore?topic={{ t }}">{{ t }}</a>{% endfor %}</p>{% endif %}
34 +
    <p class="small mute counts-line"><span title="stars">★ {{ stars }}</span> · <span title="watchers">👁 {{ watchers }}</span></p>
35 +
    {% if shell.user.is_some() %}<form class="inline" method="post" action="{{ repo.url }}/star"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit" class="small">{% if starred %}★ UNSTAR{% else %}☆ STAR{% endif %}</button></form>
36 +
    <form class="inline" method="post" action="{{ repo.url }}/watch"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit" class="small">{% if watching %}UNWATCH{% else %}WATCH{% endif %}</button></form>
34 37
    <a class="go small" href="{{ repo.url }}/fork">fork</a>{% endif %}
35 38
    {% if fork.forks > 0 %}<a class="small" href="{{ repo.url }}/forks">{{ fork.forks|plural("fork", "forks") }}</a>{% endif %}
36 39
    </div>
…
18 unchanged lines
modified templates/repo_settings.html +2 −0
…
10 unchanged lines
11 11
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
12 12
  <h2>General</h2>
13 13
  <label for="description">Description</label><input type="text" id="description" name="description" value="{{ row.description }}">
14 +
  <label for="topics">Topics</label><input type="text" id="topics" name="topics" value="{{ row.topics }}" placeholder="rust version-control retro">
15 +
  <p class="small mute">Separated by spaces or commas; letters, digits and dashes. People find repositories by them under Explore.</p>
14 16
  <label for="default_bookmark">Default bookmark</label><input type="text" id="default_bookmark" name="default_bookmark" value="{{ row.default_bookmark }}">
15 17
  <label>Visibility{% if role != crate::auth::Role::Admin %} <span class="mute">(admins only)</span>{% endif %}</label>
16 18
  <div class="choice">
…
77 unchanged lines
modified templates/style.css +7 −0
…
354 unchanged lines
355 355
.profile .keys li { margin-bottom: 6px; }
356 356
.profile .side-layout, .side-layout.profile { grid-template-columns: 260px 1fr; }
357 357
@media (max-width: 760px) { .side-layout.profile { grid-template-columns: 1fr; } img.avatar.xl { max-width: 120px; } }
358 +
359 +
/* topics, stars, explore */
360 +
.topics { margin: 4px 0 0; display: flex; flex-wrap: wrap; gap: 4px; }
361 +
.topic { display: inline-block; font-size: 12px; padding: 1px 8px; border-radius: 10px; background: color-mix(in srgb, var(--acc) 14%, transparent); color: var(--acc); }
362 +
.topic:hover { text-decoration: none; background: color-mix(in srgb, var(--acc) 24%, transparent); }
363 +
.explore-bar input[type=search] { flex: 1 1 240px; }
364 +
.counts-line span { margin-right: 2px; }