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