Projects: boards of issues, proposals and notes across an owner's repositories, with columns, cards moved without JavaScript, private projects, and the projects shown on issues and proposals e1ec9fbf538e
closes #5
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-28 14:56 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
e1ec9fbf538eea2cedb1c7ae7b4cb6f4f7a83d1d5b9e310a061892892295f330- Depends on
- 101740454617 18e381ea88c2 19bb31ff1f5c 1ac84c62ba53 43e8ca33bc5d 756fe44a0f4d 93be9ce4653e b64e2537d702 d23464c0ac60 de505df5cf8d eb0c44a9ad37
- Depended on by
- 40a43921ef42 f2d2776b2a14 0bc95135954d 306aec1839aa 046e3352cdfd 1000b40a2629 946fe7a09faa 439ecd8708a1 6a306cd03fc1 657e7d5c9da9 456f4197ade3 16ee5d19d220 a7aa76ac52d7 d1bf2d698777 5d90e0e045e2 61ecbd299f22 7f283f8e8b97 b1e92a6233ee 7b26e6118bc0 a024e800af4e efb51b39db1c 8e96499f2705 cb71e18eb6f9 cc1cd197df1d a1b9869565aa 38833ed78707 941abdd6bb4c ef20fdc14e8b c79a1111dd56 decb6304ba97
- 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
- 23 atoms, 5 tree operations, 17 files
Browse the tree at this change
added crates/hub/migrations/0015_projects.sql +38 −0
| 1 | + | -- Projects: boards of issues, proposals and notes across one owner's |
|
| 2 | + | -- repositories (hub spec §8.2). |
|
| 3 | + | ||
| 4 | + | CREATE TABLE projects ( |
|
| 5 | + | id INTEGER PRIMARY KEY, |
|
| 6 | + | owner TEXT NOT NULL, |
|
| 7 | + | number INTEGER NOT NULL, |
|
| 8 | + | title TEXT NOT NULL, |
|
| 9 | + | description TEXT NOT NULL DEFAULT '', |
|
| 10 | + | public INTEGER NOT NULL DEFAULT 1, |
|
| 11 | + | closed INTEGER NOT NULL DEFAULT 0, |
|
| 12 | + | created_ms INTEGER NOT NULL, |
|
| 13 | + | updated_ms INTEGER NOT NULL, |
|
| 14 | + | UNIQUE (owner, number) |
|
| 15 | + | ); |
|
| 16 | + | ||
| 17 | + | CREATE TABLE project_columns ( |
|
| 18 | + | id INTEGER PRIMARY KEY, |
|
| 19 | + | project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, |
|
| 20 | + | name TEXT NOT NULL, |
|
| 21 | + | position INTEGER NOT NULL |
|
| 22 | + | ); |
|
| 23 | + | CREATE INDEX project_columns_by_project ON project_columns (project_id, position); |
|
| 24 | + | ||
| 25 | + | CREATE TABLE project_cards ( |
|
| 26 | + | id INTEGER PRIMARY KEY, |
|
| 27 | + | project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, |
|
| 28 | + | column_id INTEGER NOT NULL REFERENCES project_columns(id) ON DELETE CASCADE, |
|
| 29 | + | position INTEGER NOT NULL, |
|
| 30 | + | kind TEXT NOT NULL CHECK (kind IN ('issue', 'proposal', 'note')), |
|
| 31 | + | repo_id INTEGER REFERENCES repositories(id) ON DELETE CASCADE, |
|
| 32 | + | number INTEGER, |
|
| 33 | + | note TEXT NOT NULL DEFAULT '', |
|
| 34 | + | added_by TEXT NOT NULL DEFAULT '', |
|
| 35 | + | created_ms INTEGER NOT NULL |
|
| 36 | + | ); |
|
| 37 | + | CREATE INDEX project_cards_by_column ON project_cards (column_id, position); |
|
| 38 | + | CREATE UNIQUE INDEX project_cards_once ON project_cards (project_id, repo_id, number) WHERE kind <> 'note'; |
added crates/hub/src/db/projects.rs +281 −0
| 1 | + | //! Projects, their columns and cards (hub spec §8.2). |
|
| 2 | + | ||
| 3 | + | use super::{now_ms, Pool}; |
|
| 4 | + | use sqlx::FromRow; |
|
| 5 | + | ||
| 6 | + | #[derive(Clone, Debug, FromRow)] |
|
| 7 | + | pub struct Project { |
|
| 8 | + | pub id: i64, |
|
| 9 | + | pub owner: String, |
|
| 10 | + | pub number: i64, |
|
| 11 | + | pub title: String, |
|
| 12 | + | pub description: String, |
|
| 13 | + | pub public: bool, |
|
| 14 | + | pub closed: bool, |
|
| 15 | + | pub created_ms: i64, |
|
| 16 | + | pub updated_ms: i64, |
|
| 17 | + | } |
|
| 18 | + | ||
| 19 | + | #[derive(Clone, Debug, FromRow)] |
|
| 20 | + | pub struct Column { |
|
| 21 | + | pub id: i64, |
|
| 22 | + | pub project_id: i64, |
|
| 23 | + | pub name: String, |
|
| 24 | + | pub position: i64, |
|
| 25 | + | } |
|
| 26 | + | ||
| 27 | + | #[derive(Clone, Debug, FromRow)] |
|
| 28 | + | pub struct Card { |
|
| 29 | + | pub id: i64, |
|
| 30 | + | pub project_id: i64, |
|
| 31 | + | pub column_id: i64, |
|
| 32 | + | pub position: i64, |
|
| 33 | + | pub kind: String, |
|
| 34 | + | pub repo_id: Option<i64>, |
|
| 35 | + | pub number: Option<i64>, |
|
| 36 | + | pub note: String, |
|
| 37 | + | pub added_by: String, |
|
| 38 | + | pub created_ms: i64, |
|
| 39 | + | } |
|
| 40 | + | ||
| 41 | + | pub const DEFAULT_COLUMNS: [&str; 3] = ["Backlog", "Doing", "Done"]; |
|
| 42 | + | ||
| 43 | + | pub async fn create(pool: &Pool, owner: &str, title: &str, description: &str, public: bool) -> sqlx::Result<Project> { |
|
| 44 | + | let mut tx = pool.begin().await?; |
|
| 45 | + | let number: i64 = |
|
| 46 | + | sqlx::query_scalar("SELECT COALESCE(MAX(number), 0) + 1 FROM projects WHERE owner = ?").bind(owner).fetch_one(&mut *tx).await?; |
|
| 47 | + | let now = now_ms(); |
|
| 48 | + | let p: Project = sqlx::query_as( |
|
| 49 | + | "INSERT INTO projects (owner, number, title, description, public, created_ms, updated_ms) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING *", |
|
| 50 | + | ) |
|
| 51 | + | .bind(owner) |
|
| 52 | + | .bind(number) |
|
| 53 | + | .bind(title) |
|
| 54 | + | .bind(description) |
|
| 55 | + | .bind(public) |
|
| 56 | + | .bind(now) |
|
| 57 | + | .bind(now) |
|
| 58 | + | .fetch_one(&mut *tx) |
|
| 59 | + | .await?; |
|
| 60 | + | for (i, name) in DEFAULT_COLUMNS.iter().enumerate() { |
|
| 61 | + | sqlx::query("INSERT INTO project_columns (project_id, name, position) VALUES (?, ?, ?)") |
|
| 62 | + | .bind(p.id) |
|
| 63 | + | .bind(name) |
|
| 64 | + | .bind(i as i64) |
|
| 65 | + | .execute(&mut *tx) |
|
| 66 | + | .await?; |
|
| 67 | + | } |
|
| 68 | + | tx.commit().await?; |
|
| 69 | + | Ok(p) |
|
| 70 | + | } |
|
| 71 | + | ||
| 72 | + | pub async fn of_owner(pool: &Pool, owner: &str) -> sqlx::Result<Vec<Project>> { |
|
| 73 | + | sqlx::query_as("SELECT * FROM projects WHERE owner = ? ORDER BY closed, updated_ms DESC").bind(owner).fetch_all(pool).await |
|
| 74 | + | } |
|
| 75 | + | ||
| 76 | + | pub async fn get(pool: &Pool, owner: &str, number: i64) -> sqlx::Result<Option<Project>> { |
|
| 77 | + | sqlx::query_as("SELECT * FROM projects WHERE owner = ? AND number = ?").bind(owner).bind(number).fetch_optional(pool).await |
|
| 78 | + | } |
|
| 79 | + | ||
| 80 | + | pub async fn set(pool: &Pool, id: i64, title: &str, description: &str, public: bool, closed: bool) -> sqlx::Result<()> { |
|
| 81 | + | sqlx::query("UPDATE projects SET title = ?, description = ?, public = ?, closed = ?, updated_ms = ? WHERE id = ?") |
|
| 82 | + | .bind(title) |
|
| 83 | + | .bind(description) |
|
| 84 | + | .bind(public) |
|
| 85 | + | .bind(closed) |
|
| 86 | + | .bind(now_ms()) |
|
| 87 | + | .bind(id) |
|
| 88 | + | .execute(pool) |
|
| 89 | + | .await?; |
|
| 90 | + | Ok(()) |
|
| 91 | + | } |
|
| 92 | + | ||
| 93 | + | pub async fn delete(pool: &Pool, id: i64) -> sqlx::Result<()> { |
|
| 94 | + | sqlx::query("DELETE FROM projects WHERE id = ?").bind(id).execute(pool).await?; |
|
| 95 | + | Ok(()) |
|
| 96 | + | } |
|
| 97 | + | ||
| 98 | + | async fn touch(pool: &Pool, id: i64) -> sqlx::Result<()> { |
|
| 99 | + | sqlx::query("UPDATE projects SET updated_ms = ? WHERE id = ?").bind(now_ms()).bind(id).execute(pool).await?; |
|
| 100 | + | Ok(()) |
|
| 101 | + | } |
|
| 102 | + | ||
| 103 | + | pub async fn columns(pool: &Pool, project_id: i64) -> sqlx::Result<Vec<Column>> { |
|
| 104 | + | sqlx::query_as("SELECT * FROM project_columns WHERE project_id = ? ORDER BY position, id").bind(project_id).fetch_all(pool).await |
|
| 105 | + | } |
|
| 106 | + | ||
| 107 | + | pub async fn cards(pool: &Pool, project_id: i64) -> sqlx::Result<Vec<Card>> { |
|
| 108 | + | sqlx::query_as("SELECT * FROM project_cards WHERE project_id = ? ORDER BY position, id").bind(project_id).fetch_all(pool).await |
|
| 109 | + | } |
|
| 110 | + | ||
| 111 | + | pub async fn add_column(pool: &Pool, project_id: i64, name: &str) -> sqlx::Result<()> { |
|
| 112 | + | let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_columns WHERE project_id = ?") |
|
| 113 | + | .bind(project_id) |
|
| 114 | + | .fetch_one(pool) |
|
| 115 | + | .await?; |
|
| 116 | + | sqlx::query("INSERT INTO project_columns (project_id, name, position) VALUES (?, ?, ?)") |
|
| 117 | + | .bind(project_id) |
|
| 118 | + | .bind(name) |
|
| 119 | + | .bind(pos) |
|
| 120 | + | .execute(pool) |
|
| 121 | + | .await?; |
|
| 122 | + | touch(pool, project_id).await |
|
| 123 | + | } |
|
| 124 | + | ||
| 125 | + | pub async fn rename_column(pool: &Pool, project_id: i64, column_id: i64, name: &str) -> sqlx::Result<()> { |
|
| 126 | + | sqlx::query("UPDATE project_columns SET name = ? WHERE id = ? AND project_id = ?") |
|
| 127 | + | .bind(name) |
|
| 128 | + | .bind(column_id) |
|
| 129 | + | .bind(project_id) |
|
| 130 | + | .execute(pool) |
|
| 131 | + | .await?; |
|
| 132 | + | touch(pool, project_id).await |
|
| 133 | + | } |
|
| 134 | + | ||
| 135 | + | /// Swap a column with its neighbour (`-1` left, `1` right). |
|
| 136 | + | pub async fn shift_column(pool: &Pool, project_id: i64, column_id: i64, by: i64) -> sqlx::Result<()> { |
|
| 137 | + | let cols = columns(pool, project_id).await?; |
|
| 138 | + | let Some(i) = cols.iter().position(|c| c.id == column_id) else { return Ok(()) }; |
|
| 139 | + | let j = i as i64 + by; |
|
| 140 | + | if j < 0 || j as usize >= cols.len() { |
|
| 141 | + | return Ok(()); |
|
| 142 | + | } |
|
| 143 | + | let mut ids: Vec<i64> = cols.iter().map(|c| c.id).collect(); |
|
| 144 | + | ids.swap(i, j as usize); |
|
| 145 | + | for (pos, id) in ids.iter().enumerate() { |
|
| 146 | + | sqlx::query("UPDATE project_columns SET position = ? WHERE id = ?").bind(pos as i64).bind(id).execute(pool).await?; |
|
| 147 | + | } |
|
| 148 | + | touch(pool, project_id).await |
|
| 149 | + | } |
|
| 150 | + | ||
| 151 | + | /// Remove a column; its cards go to the first remaining column. The last column stays. |
|
| 152 | + | pub async fn remove_column(pool: &Pool, project_id: i64, column_id: i64) -> sqlx::Result<bool> { |
|
| 153 | + | let cols = columns(pool, project_id).await?; |
|
| 154 | + | let Some(to) = cols.iter().find(|c| c.id != column_id) else { return Ok(false) }; |
|
| 155 | + | if !cols.iter().any(|c| c.id == column_id) { |
|
| 156 | + | return Ok(false); |
|
| 157 | + | } |
|
| 158 | + | let mut tx = pool.begin().await?; |
|
| 159 | + | let base: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = ?") |
|
| 160 | + | .bind(to.id) |
|
| 161 | + | .fetch_one(&mut *tx) |
|
| 162 | + | .await?; |
|
| 163 | + | sqlx::query("UPDATE project_cards SET column_id = ?, position = position + ? WHERE column_id = ?") |
|
| 164 | + | .bind(to.id) |
|
| 165 | + | .bind(base) |
|
| 166 | + | .bind(column_id) |
|
| 167 | + | .execute(&mut *tx) |
|
| 168 | + | .await?; |
|
| 169 | + | sqlx::query("DELETE FROM project_columns WHERE id = ?").bind(column_id).execute(&mut *tx).await?; |
|
| 170 | + | tx.commit().await?; |
|
| 171 | + | touch(pool, project_id).await?; |
|
| 172 | + | Ok(true) |
|
| 173 | + | } |
|
| 174 | + | ||
| 175 | + | pub struct NewCard<'a> { |
|
| 176 | + | pub column_id: i64, |
|
| 177 | + | pub kind: &'a str, |
|
| 178 | + | pub repo_id: Option<i64>, |
|
| 179 | + | pub number: Option<i64>, |
|
| 180 | + | pub note: &'a str, |
|
| 181 | + | pub added_by: &'a str, |
|
| 182 | + | } |
|
| 183 | + | ||
| 184 | + | /// Add a card at the bottom of a column; `Ok(false)` when that issue or |
|
| 185 | + | /// proposal is already on the project. |
|
| 186 | + | pub async fn add_card(pool: &Pool, project_id: i64, c: NewCard<'_>) -> sqlx::Result<bool> { |
|
| 187 | + | let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = ?") |
|
| 188 | + | .bind(c.column_id) |
|
| 189 | + | .fetch_one(pool) |
|
| 190 | + | .await?; |
|
| 191 | + | let r = sqlx::query( |
|
| 192 | + | "INSERT OR IGNORE INTO project_cards (project_id, column_id, position, kind, repo_id, number, note, added_by, created_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", |
|
| 193 | + | ) |
|
| 194 | + | .bind(project_id) |
|
| 195 | + | .bind(c.column_id) |
|
| 196 | + | .bind(pos) |
|
| 197 | + | .bind(c.kind) |
|
| 198 | + | .bind(c.repo_id) |
|
| 199 | + | .bind(c.number) |
|
| 200 | + | .bind(c.note) |
|
| 201 | + | .bind(c.added_by) |
|
| 202 | + | .bind(now_ms()) |
|
| 203 | + | .execute(pool) |
|
| 204 | + | .await?; |
|
| 205 | + | touch(pool, project_id).await?; |
|
| 206 | + | Ok(r.rows_affected() == 1) |
|
| 207 | + | } |
|
| 208 | + | ||
| 209 | + | /// Move a card to the bottom of another column of the same project. |
|
| 210 | + | pub async fn move_card(pool: &Pool, project_id: i64, card_id: i64, column_id: i64) -> sqlx::Result<()> { |
|
| 211 | + | let ok: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM project_columns WHERE id = ? AND project_id = ?") |
|
| 212 | + | .bind(column_id) |
|
| 213 | + | .bind(project_id) |
|
| 214 | + | .fetch_one(pool) |
|
| 215 | + | .await?; |
|
| 216 | + | if ok == 0 { |
|
| 217 | + | return Ok(()); |
|
| 218 | + | } |
|
| 219 | + | let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = ?") |
|
| 220 | + | .bind(column_id) |
|
| 221 | + | .fetch_one(pool) |
|
| 222 | + | .await?; |
|
| 223 | + | sqlx::query("UPDATE project_cards SET column_id = ?, position = ? WHERE id = ? AND project_id = ?") |
|
| 224 | + | .bind(column_id) |
|
| 225 | + | .bind(pos) |
|
| 226 | + | .bind(card_id) |
|
| 227 | + | .bind(project_id) |
|
| 228 | + | .execute(pool) |
|
| 229 | + | .await?; |
|
| 230 | + | touch(pool, project_id).await |
|
| 231 | + | } |
|
| 232 | + | ||
| 233 | + | /// Swap a card with its neighbour in its column (`-1` up, `1` down). |
|
| 234 | + | pub async fn shift_card(pool: &Pool, project_id: i64, card_id: i64, by: i64) -> sqlx::Result<()> { |
|
| 235 | + | let Some(card): Option<Card> = sqlx::query_as("SELECT * FROM project_cards WHERE id = ? AND project_id = ?") |
|
| 236 | + | .bind(card_id) |
|
| 237 | + | .bind(project_id) |
|
| 238 | + | .fetch_optional(pool) |
|
| 239 | + | .await? |
|
| 240 | + | else { |
|
| 241 | + | return Ok(()); |
|
| 242 | + | }; |
|
| 243 | + | let col: Vec<Card> = sqlx::query_as("SELECT * FROM project_cards WHERE column_id = ? ORDER BY position, id") |
|
| 244 | + | .bind(card.column_id) |
|
| 245 | + | .fetch_all(pool) |
|
| 246 | + | .await?; |
|
| 247 | + | let Some(i) = col.iter().position(|c| c.id == card_id) else { return Ok(()) }; |
|
| 248 | + | let j = i as i64 + by; |
|
| 249 | + | if j < 0 || j as usize >= col.len() { |
|
| 250 | + | return Ok(()); |
|
| 251 | + | } |
|
| 252 | + | let mut ids: Vec<i64> = col.iter().map(|c| c.id).collect(); |
|
| 253 | + | ids.swap(i, j as usize); |
|
| 254 | + | for (pos, id) in ids.iter().enumerate() { |
|
| 255 | + | sqlx::query("UPDATE project_cards SET position = ? WHERE id = ?").bind(pos as i64).bind(id).execute(pool).await?; |
|
| 256 | + | } |
|
| 257 | + | touch(pool, project_id).await |
|
| 258 | + | } |
|
| 259 | + | ||
| 260 | + | pub async fn remove_card(pool: &Pool, project_id: i64, card_id: i64) -> sqlx::Result<()> { |
|
| 261 | + | sqlx::query("DELETE FROM project_cards WHERE id = ? AND project_id = ?").bind(card_id).bind(project_id).execute(pool).await?; |
|
| 262 | + | touch(pool, project_id).await |
|
| 263 | + | } |
|
| 264 | + | ||
| 265 | + | /// The projects (and column names) an issue or proposal is on. |
|
| 266 | + | pub async fn placements(pool: &Pool, repo_id: i64, number: i64) -> sqlx::Result<Vec<(Project, String)>> { |
|
| 267 | + | let rows: Vec<(i64, String)> = sqlx::query_as( |
|
| 268 | + | "SELECT c.project_id, col.name FROM project_cards c JOIN project_columns col ON col.id = c.column_id WHERE c.repo_id = ? AND c.number = ? AND c.kind <> 'note'", |
|
| 269 | + | ) |
|
| 270 | + | .bind(repo_id) |
|
| 271 | + | .bind(number) |
|
| 272 | + | .fetch_all(pool) |
|
| 273 | + | .await?; |
|
| 274 | + | let mut out = Vec::new(); |
|
| 275 | + | for (pid, col) in rows { |
|
| 276 | + | if let Some(p) = sqlx::query_as::<_, Project>("SELECT * FROM projects WHERE id = ?").bind(pid).fetch_optional(pool).await? { |
|
| 277 | + | out.push((p, col)); |
|
| 278 | + | } |
|
| 279 | + | } |
|
| 280 | + | Ok(out) |
|
| 281 | + | } |
added crates/hub/src/web/projects.rs +427 −0
| 1 | + | //! Projects (hub spec §8.2): boards of issues, proposals and notes across |
|
| 2 | + | //! the repositories of one person or organisation. |
|
| 3 | + | ||
| 4 | + | use super::settings::csrf; |
|
| 5 | + | use super::{filters, Error, Page, Shell}; |
|
| 6 | + | use crate::auth::{roles, Access, Viewer}; |
|
| 7 | + | use crate::db::{self, projects as pdb}; |
|
| 8 | + | use crate::Shared; |
|
| 9 | + | use askama::Template; |
|
| 10 | + | use axum::extract::{Form, Path, State}; |
|
| 11 | + | use axum::response::{IntoResponse, Redirect, Response}; |
|
| 12 | + | use serde::Deserialize; |
|
| 13 | + | ||
| 14 | + | /// What the viewer may do with an owner's projects. |
|
| 15 | + | struct Rights { |
|
| 16 | + | /// Sees private projects. |
|
| 17 | + | member: bool, |
|
| 18 | + | /// Creates and changes projects. |
|
| 19 | + | edit: bool, |
|
| 20 | + | } |
|
| 21 | + | ||
| 22 | + | async fn rights(app: &Shared, shell: &Shell, viewer: &Viewer, owner: &str) -> Result<Rights, Error> { |
|
| 23 | + | let e = |e| Error::from_db(shell.clone(), e); |
|
| 24 | + | let admin = viewer.is_admin(); |
|
| 25 | + | if let Some(a) = db::accounts::account_by_name(&app.db, owner).await.map_err(e)? { |
|
| 26 | + | let me = viewer.account().is_some_and(|v| v.id == a.id); |
|
| 27 | + | return Ok(Rights { member: me || admin, edit: me || admin }); |
|
| 28 | + | } |
|
| 29 | + | if let Some(o) = db::accounts::org_by_name(&app.db, owner).await.map_err(e)? { |
|
| 30 | + | let member = match viewer.account() { |
|
| 31 | + | Some(v) => db::accounts::org_role(&app.db, o.id, v.id).await.map_err(e)?.is_some(), |
|
| 32 | + | None => false, |
|
| 33 | + | }; |
|
| 34 | + | return Ok(Rights { member: member || admin, edit: member || admin }); |
|
| 35 | + | } |
|
| 36 | + | Err(Error::not_found(shell.clone())) |
|
| 37 | + | } |
|
| 38 | + | ||
| 39 | + | #[derive(Template)] |
|
| 40 | + | #[template(path = "projects.html")] |
|
| 41 | + | pub struct ProjectsPage { |
|
| 42 | + | shell: Shell, |
|
| 43 | + | owner: String, |
|
| 44 | + | projects: Vec<pdb::Project>, |
|
| 45 | + | can_edit: bool, |
|
| 46 | + | error: Option<String>, |
|
| 47 | + | } |
|
| 48 | + | ||
| 49 | + | pub async fn list(State(app): State<Shared>, viewer: Viewer, Path(owner): Path<String>) -> Result<Page<ProjectsPage>, Error> { |
|
| 50 | + | let shell = Shell::new(&app, &format!("/projects/{owner}"), &viewer); |
|
| 51 | + | let r = rights(&app, &shell, &viewer, &owner).await?; |
|
| 52 | + | let projects = pdb::of_owner(&app.db, &owner) |
|
| 53 | + | .await |
|
| 54 | + | .map_err(|e| Error::from_db(shell.clone(), e))? |
|
| 55 | + | .into_iter() |
|
| 56 | + | .filter(|p| p.public || r.member) |
|
| 57 | + | .collect(); |
|
| 58 | + | Ok(Page(ProjectsPage { shell, owner, projects, can_edit: r.edit, error: None })) |
|
| 59 | + | } |
|
| 60 | + | ||
| 61 | + | #[derive(Deserialize)] |
|
| 62 | + | pub struct NewProjectForm { |
|
| 63 | + | #[serde(rename = "_csrf")] |
|
| 64 | + | csrf_token: String, |
|
| 65 | + | title: String, |
|
| 66 | + | #[serde(default)] |
|
| 67 | + | description: String, |
|
| 68 | + | #[serde(default)] |
|
| 69 | + | private: Option<String>, |
|
| 70 | + | } |
|
| 71 | + | ||
| 72 | + | pub async fn create( |
|
| 73 | + | State(app): State<Shared>, |
|
| 74 | + | viewer: Viewer, |
|
| 75 | + | Path(owner): Path<String>, |
|
| 76 | + | Form(f): Form<NewProjectForm>, |
|
| 77 | + | ) -> Result<Response, Error> { |
|
| 78 | + | let shell = Shell::new(&app, &format!("/projects/{owner}"), &viewer); |
|
| 79 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 80 | + | let r = rights(&app, &shell, &viewer, &owner).await?; |
|
| 81 | + | if !r.edit { |
|
| 82 | + | return Err(Error::not_found(shell)); |
|
| 83 | + | } |
|
| 84 | + | let title = f.title.trim(); |
|
| 85 | + | if title.is_empty() || title.len() > 100 { |
|
| 86 | + | let projects = pdb::of_owner(&app.db, &owner).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 87 | + | return Ok(Page(ProjectsPage { |
|
| 88 | + | shell, |
|
| 89 | + | owner, |
|
| 90 | + | projects, |
|
| 91 | + | can_edit: true, |
|
| 92 | + | error: Some("A project needs a title of up to 100 characters.".into()), |
|
| 93 | + | }) |
|
| 94 | + | .into_response()); |
|
| 95 | + | } |
|
| 96 | + | let p = pdb::create(&app.db, &owner, title, f.description.trim(), f.private.is_none()) |
|
| 97 | + | .await |
|
| 98 | + | .map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 99 | + | db::accounts::audit(&app.db, viewer.account(), "project.created", &format!("{owner}/{}", p.number), title).await.ok(); |
|
| 100 | + | Ok(Redirect::to(&format!("/projects/{owner}/{}", p.number)).into_response()) |
|
| 101 | + | } |
|
| 102 | + | ||
| 103 | + | /// A card as the board shows it. |
|
| 104 | + | pub struct CardView { |
|
| 105 | + | pub card: pdb::Card, |
|
| 106 | + | /// `owner/name#N`, for issue and proposal cards. |
|
| 107 | + | pub reference: String, |
|
| 108 | + | pub url: String, |
|
| 109 | + | pub title: String, |
|
| 110 | + | /// `open`, `closed`, `landed`, `draft`… for issues and proposals. |
|
| 111 | + | pub state: String, |
|
| 112 | + | } |
|
| 113 | + | ||
| 114 | + | pub struct ColumnView { |
|
| 115 | + | pub column: pdb::Column, |
|
| 116 | + | pub cards: Vec<CardView>, |
|
| 117 | + | } |
|
| 118 | + | ||
| 119 | + | #[derive(Template)] |
|
| 120 | + | #[template(path = "project.html")] |
|
| 121 | + | pub struct BoardPage { |
|
| 122 | + | shell: Shell, |
|
| 123 | + | project: pdb::Project, |
|
| 124 | + | columns: Vec<ColumnView>, |
|
| 125 | + | /// The owner's repositories the viewer can read, for adding cards. |
|
| 126 | + | repos: Vec<String>, |
|
| 127 | + | /// Changes cards and columns (an open project). |
|
| 128 | + | can_edit: bool, |
|
| 129 | + | /// Changes the settings (to reopen a closed project too). |
|
| 130 | + | can_manage: bool, |
|
| 131 | + | notice: Option<String>, |
|
| 132 | + | error: Option<String>, |
|
| 133 | + | } |
|
| 134 | + | ||
| 135 | + | async fn board( |
|
| 136 | + | app: &Shared, |
|
| 137 | + | shell: Shell, |
|
| 138 | + | viewer: &Viewer, |
|
| 139 | + | owner: &str, |
|
| 140 | + | number: i64, |
|
| 141 | + | notice: Option<String>, |
|
| 142 | + | error: Option<String>, |
|
| 143 | + | ) -> Result<Page<BoardPage>, Error> { |
|
| 144 | + | let e = |e| Error::from_db(shell.clone(), e); |
|
| 145 | + | let r = rights(app, &shell, viewer, owner).await?; |
|
| 146 | + | let project = pdb::get(&app.db, owner, number) |
|
| 147 | + | .await |
|
| 148 | + | .map_err(e)? |
|
| 149 | + | .filter(|p| p.public || r.member) |
|
| 150 | + | .ok_or_else(|| Error::not_found(shell.clone()))?; |
|
| 151 | + | let mut readable: std::collections::HashMap<i64, db::repos::RepoRow> = Default::default(); |
|
| 152 | + | for row in db::repos::list_by_owner(&app.db, owner).await.map_err(e)? { |
|
| 153 | + | let role = roles::role_for(&app.db, viewer.account(), &row).await.map_err(e)?; |
|
| 154 | + | if roles::allows(role, Access::Read) { |
|
| 155 | + | readable.insert(row.id, row); |
|
| 156 | + | } |
|
| 157 | + | } |
|
| 158 | + | let cards = pdb::cards(&app.db, project.id).await.map_err(e)?; |
|
| 159 | + | let mut columns = Vec::new(); |
|
| 160 | + | for column in pdb::columns(&app.db, project.id).await.map_err(e)? { |
|
| 161 | + | let mut views = Vec::new(); |
|
| 162 | + | for card in cards.iter().filter(|c| c.column_id == column.id) { |
|
| 163 | + | match (card.kind.as_str(), card.repo_id, card.number) { |
|
| 164 | + | ("note", _, _) => views.push(CardView { |
|
| 165 | + | card: card.clone(), |
|
| 166 | + | reference: String::new(), |
|
| 167 | + | url: String::new(), |
|
| 168 | + | title: card.note.clone(), |
|
| 169 | + | state: String::new(), |
|
| 170 | + | }), |
|
| 171 | + | (kind, Some(rid), Some(n)) => { |
|
| 172 | + | // Cards of repositories the viewer cannot read are not shown at all. |
|
| 173 | + | let Some(row) = readable.get(&rid) else { continue }; |
|
| 174 | + | let (title, state) = if kind == "issue" { |
|
| 175 | + | match db::issues::get(&app.db, rid, n).await.map_err(e)? { |
|
| 176 | + | Some(i) => (i.title, i.state), |
|
| 177 | + | None => continue, |
|
| 178 | + | } |
|
| 179 | + | } else { |
|
| 180 | + | match db::proposals::get(&app.db, rid, n).await.map_err(e)? { |
|
| 181 | + | Some(p) => (p.title, p.state), |
|
| 182 | + | None => continue, |
|
| 183 | + | } |
|
| 184 | + | }; |
|
| 185 | + | let slug = format!("{}/{}", row.owner, row.name); |
|
| 186 | + | views.push(CardView { |
|
| 187 | + | card: card.clone(), |
|
| 188 | + | reference: format!("{slug}#{n}"), |
|
| 189 | + | url: format!("/{slug}/{kind}/{n}"), |
|
| 190 | + | title, |
|
| 191 | + | state, |
|
| 192 | + | }); |
|
| 193 | + | } |
|
| 194 | + | _ => {} |
|
| 195 | + | } |
|
| 196 | + | } |
|
| 197 | + | columns.push(ColumnView { column, cards: views }); |
|
| 198 | + | } |
|
| 199 | + | let mut repos: Vec<String> = readable.values().map(|r| r.name.clone()).collect(); |
|
| 200 | + | repos.sort(); |
|
| 201 | + | let can_edit = r.edit && !project.closed; |
|
| 202 | + | Ok(Page(BoardPage { shell, project, columns, repos, can_edit, can_manage: r.edit, notice, error })) |
|
| 203 | + | } |
|
| 204 | + | ||
| 205 | + | pub async fn show(State(app): State<Shared>, viewer: Viewer, Path((owner, number)): Path<(String, i64)>) -> Result<Page<BoardPage>, Error> { |
|
| 206 | + | let shell = Shell::new(&app, &format!("/projects/{owner}/{number}"), &viewer); |
|
| 207 | + | board(&app, shell, &viewer, &owner, number, None, None).await |
|
| 208 | + | } |
|
| 209 | + | ||
| 210 | + | #[derive(Deserialize)] |
|
| 211 | + | pub struct BoardForm { |
|
| 212 | + | #[serde(rename = "_csrf")] |
|
| 213 | + | csrf_token: String, |
|
| 214 | + | action: String, |
|
| 215 | + | #[serde(default)] |
|
| 216 | + | card: Option<i64>, |
|
| 217 | + | #[serde(default)] |
|
| 218 | + | column: Option<i64>, |
|
| 219 | + | #[serde(default)] |
|
| 220 | + | name: String, |
|
| 221 | + | /// `repo#12`, or `#12` with `repo`. |
|
| 222 | + | #[serde(default)] |
|
| 223 | + | reference: String, |
|
| 224 | + | #[serde(default)] |
|
| 225 | + | repo: String, |
|
| 226 | + | #[serde(default)] |
|
| 227 | + | note: String, |
|
| 228 | + | #[serde(default)] |
|
| 229 | + | title: String, |
|
| 230 | + | #[serde(default)] |
|
| 231 | + | description: String, |
|
| 232 | + | #[serde(default)] |
|
| 233 | + | private: Option<String>, |
|
| 234 | + | #[serde(default)] |
|
| 235 | + | closed: Option<String>, |
|
| 236 | + | } |
|
| 237 | + | ||
| 238 | + | /// `repo#12` or `#12` (with a repository chosen) to (repository, number). |
|
| 239 | + | fn parse_reference(reference: &str, repo: &str) -> Option<(String, i64)> { |
|
| 240 | + | let r = reference.trim(); |
|
| 241 | + | let (name, n) = r.split_once('#')?; |
|
| 242 | + | let name = if name.is_empty() { repo.trim() } else { name.trim() }; |
|
| 243 | + | let n: i64 = n.trim().parse().ok()?; |
|
| 244 | + | (!name.is_empty() && n > 0 && !name.contains('/')).then(|| (name.to_string(), n)) |
|
| 245 | + | } |
|
| 246 | + | ||
| 247 | + | pub async fn act( |
|
| 248 | + | State(app): State<Shared>, |
|
| 249 | + | viewer: Viewer, |
|
| 250 | + | Path((owner, number)): Path<(String, i64)>, |
|
| 251 | + | Form(f): Form<BoardForm>, |
|
| 252 | + | ) -> Result<Response, Error> { |
|
| 253 | + | let shell = Shell::new(&app, &format!("/projects/{owner}/{number}"), &viewer); |
|
| 254 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 255 | + | let r = rights(&app, &shell, &viewer, &owner).await?; |
|
| 256 | + | let e = |e| Error::from_db(shell.clone(), e); |
|
| 257 | + | let project = pdb::get(&app.db, &owner, number) |
|
| 258 | + | .await |
|
| 259 | + | .map_err(e)? |
|
| 260 | + | .filter(|p| p.public || r.member) |
|
| 261 | + | .ok_or_else(|| Error::not_found(shell.clone()))?; |
|
| 262 | + | if !r.edit { |
|
| 263 | + | return Err(Error::not_found(shell)); |
|
| 264 | + | } |
|
| 265 | + | if project.closed && !matches!(f.action.as_str(), "settings" | "delete") { |
|
| 266 | + | return Ok(board(&app, shell, &viewer, &owner, number, None, Some("This project is closed; reopen it to change it.".into())) |
|
| 267 | + | .await? |
|
| 268 | + | .into_response()); |
|
| 269 | + | } |
|
| 270 | + | let actor = viewer.account().map(|a| a.username.clone()).unwrap_or_default(); |
|
| 271 | + | let columns = pdb::columns(&app.db, project.id).await.map_err(e)?; |
|
| 272 | + | let column = f.column.filter(|c| columns.iter().any(|x| x.id == *c)).or(columns.first().map(|c| c.id)); |
|
| 273 | + | let mut notice = None; |
|
| 274 | + | let mut error = None; |
|
| 275 | + | match f.action.as_str() { |
|
| 276 | + | "add" => { |
|
| 277 | + | let Some(column_id) = column else { return Err(Error::bad(shell, "no column")) }; |
|
| 278 | + | if !f.note.trim().is_empty() { |
|
| 279 | + | let note: String = f.note.trim().chars().take(500).collect(); |
|
| 280 | + | pdb::add_card( |
|
| 281 | + | &app.db, |
|
| 282 | + | project.id, |
|
| 283 | + | pdb::NewCard { column_id, kind: "note", repo_id: None, number: None, note: ¬e, added_by: &actor }, |
|
| 284 | + | ) |
|
| 285 | + | .await |
|
| 286 | + | .map_err(e)?; |
|
| 287 | + | } else { |
|
| 288 | + | match parse_reference(&f.reference, &f.repo) { |
|
| 289 | + | None => error = Some("Name an issue or proposal as repo#12, or write a note.".to_string()), |
|
| 290 | + | Some((repo, n)) => match db::repos::get(&app.db, &owner, &repo).await.map_err(e)? { |
|
| 291 | + | None => error = Some(format!("{owner} has no repository {repo}.")), |
|
| 292 | + | Some(row) => { |
|
| 293 | + | let role = roles::role_for(&app.db, viewer.account(), &row).await.map_err(e)?; |
|
| 294 | + | let (issues, proposals) = db::issues::numbers(&app.db, row.id).await.map_err(e)?; |
|
| 295 | + | let kind = if issues.contains(&n) { |
|
| 296 | + | Some("issue") |
|
| 297 | + | } else if proposals.contains(&n) { |
|
| 298 | + | Some("proposal") |
|
| 299 | + | } else { |
|
| 300 | + | None |
|
| 301 | + | }; |
|
| 302 | + | match (roles::allows(role, Access::Read), kind) { |
|
| 303 | + | (true, Some(kind)) => { |
|
| 304 | + | let added = pdb::add_card( |
|
| 305 | + | &app.db, |
|
| 306 | + | project.id, |
|
| 307 | + | pdb::NewCard { |
|
| 308 | + | column_id, |
|
| 309 | + | kind, |
|
| 310 | + | repo_id: Some(row.id), |
|
| 311 | + | number: Some(n), |
|
| 312 | + | note: "", |
|
| 313 | + | added_by: &actor, |
|
| 314 | + | }, |
|
| 315 | + | ) |
|
| 316 | + | .await |
|
| 317 | + | .map_err(e)?; |
|
| 318 | + | if !added { |
|
| 319 | + | error = Some(format!("{repo}#{n} is already on this project.")); |
|
| 320 | + | } |
|
| 321 | + | } |
|
| 322 | + | _ => error = Some(format!("{repo} has no issue or proposal #{n}.")), |
|
| 323 | + | } |
|
| 324 | + | } |
|
| 325 | + | }, |
|
| 326 | + | } |
|
| 327 | + | } |
|
| 328 | + | } |
|
| 329 | + | "move" => { |
|
| 330 | + | if let (Some(card), Some(col)) = (f.card, f.column) { |
|
| 331 | + | pdb::move_card(&app.db, project.id, card, col).await.map_err(e)?; |
|
| 332 | + | } |
|
| 333 | + | } |
|
| 334 | + | "up" | "down" => { |
|
| 335 | + | if let Some(card) = f.card { |
|
| 336 | + | pdb::shift_card(&app.db, project.id, card, if f.action == "up" { -1 } else { 1 }).await.map_err(e)?; |
|
| 337 | + | } |
|
| 338 | + | } |
|
| 339 | + | "remove" => { |
|
| 340 | + | if let Some(card) = f.card { |
|
| 341 | + | pdb::remove_card(&app.db, project.id, card).await.map_err(e)?; |
|
| 342 | + | } |
|
| 343 | + | } |
|
| 344 | + | "add_column" => { |
|
| 345 | + | let name = f.name.trim(); |
|
| 346 | + | if name.is_empty() || name.len() > 40 { |
|
| 347 | + | error = Some("A column has a name of up to 40 characters.".into()); |
|
| 348 | + | } else { |
|
| 349 | + | pdb::add_column(&app.db, project.id, name).await.map_err(e)?; |
|
| 350 | + | } |
|
| 351 | + | } |
|
| 352 | + | "rename_column" => { |
|
| 353 | + | if let (Some(col), name) = (f.column, f.name.trim()) { |
|
| 354 | + | if !name.is_empty() && name.len() <= 40 { |
|
| 355 | + | pdb::rename_column(&app.db, project.id, col, name).await.map_err(e)?; |
|
| 356 | + | } |
|
| 357 | + | } |
|
| 358 | + | } |
|
| 359 | + | "left" | "right" => { |
|
| 360 | + | if let Some(col) = f.column { |
|
| 361 | + | pdb::shift_column(&app.db, project.id, col, if f.action == "left" { -1 } else { 1 }).await.map_err(e)?; |
|
| 362 | + | } |
|
| 363 | + | } |
|
| 364 | + | "remove_column" => { |
|
| 365 | + | if let Some(col) = f.column { |
|
| 366 | + | if !pdb::remove_column(&app.db, project.id, col).await.map_err(e)? { |
|
| 367 | + | error = Some("A project keeps at least one column.".into()); |
|
| 368 | + | } |
|
| 369 | + | } |
|
| 370 | + | } |
|
| 371 | + | "settings" => { |
|
| 372 | + | let title = if f.title.trim().is_empty() { project.title.clone() } else { f.title.trim().chars().take(100).collect() }; |
|
| 373 | + | pdb::set(&app.db, project.id, &title, f.description.trim(), f.private.is_none(), f.closed.is_some()).await.map_err(e)?; |
|
| 374 | + | notice = Some("Saved.".into()); |
|
| 375 | + | } |
|
| 376 | + | "delete" => { |
|
| 377 | + | pdb::delete(&app.db, project.id).await.map_err(e)?; |
|
| 378 | + | db::accounts::audit(&app.db, viewer.account(), "project.deleted", &format!("{owner}/{number}"), &project.title).await.ok(); |
|
| 379 | + | return Ok(Redirect::to(&format!("/projects/{owner}")).into_response()); |
|
| 380 | + | } |
|
| 381 | + | _ => return Err(Error::bad(shell, "unknown action")), |
|
| 382 | + | } |
|
| 383 | + | if notice.is_none() && error.is_none() { |
|
| 384 | + | // Back to the board, so a reload does not repeat the action. |
|
| 385 | + | return Ok(Redirect::to(&format!("/projects/{owner}/{number}")).into_response()); |
|
| 386 | + | } |
|
| 387 | + | Ok(board(&app, shell, &viewer, &owner, number, notice, error).await?.into_response()) |
|
| 388 | + | } |
|
| 389 | + | ||
| 390 | + | /// The projects an issue or proposal is on that the viewer may see, with |
|
| 391 | + | /// their column. |
|
| 392 | + | pub async fn placements_for(app: &Shared, viewer: &Viewer, repo: &db::repos::RepoRow, number: i64) -> Vec<(pdb::Project, String)> { |
|
| 393 | + | let Ok(all) = pdb::placements(&app.db, repo.id, number).await else { return vec![] }; |
|
| 394 | + | let mut out = Vec::new(); |
|
| 395 | + | for (p, col) in all { |
|
| 396 | + | let visible = p.public |
|
| 397 | + | || viewer.is_admin() |
|
| 398 | + | || match viewer.account() { |
|
| 399 | + | Some(v) if v.username == p.owner => true, |
|
| 400 | + | Some(v) => match db::accounts::org_by_name(&app.db, &p.owner).await.ok().flatten() { |
|
| 401 | + | Some(o) => db::accounts::org_role(&app.db, o.id, v.id).await.ok().flatten().is_some(), |
|
| 402 | + | None => false, |
|
| 403 | + | }, |
|
| 404 | + | None => false, |
|
| 405 | + | }; |
|
| 406 | + | if visible { |
|
| 407 | + | out.push((p, col)); |
|
| 408 | + | } |
|
| 409 | + | } |
|
| 410 | + | out |
|
| 411 | + | } |
|
| 412 | + | ||
| 413 | + | #[cfg(test)] |
|
| 414 | + | mod tests { |
|
| 415 | + | use super::parse_reference; |
|
| 416 | + | ||
| 417 | + | #[test] |
|
| 418 | + | fn references() { |
|
| 419 | + | assert_eq!(parse_reference("oxygen#12", ""), Some(("oxygen".into(), 12))); |
|
| 420 | + | assert_eq!(parse_reference("#7", "hub"), Some(("hub".into(), 7))); |
|
| 421 | + | assert_eq!(parse_reference(" hub # 3 ", ""), Some(("hub".into(), 3))); |
|
| 422 | + | assert_eq!(parse_reference("#7", ""), None, "a repository is needed"); |
|
| 423 | + | assert_eq!(parse_reference("other/repo#1", ""), None, "only the owner's repositories"); |
|
| 424 | + | assert_eq!(parse_reference("hub#0", ""), None); |
|
| 425 | + | assert_eq!(parse_reference("hub", ""), None); |
|
| 426 | + | } |
|
| 427 | + | } |
added templates/project.html +59 −0
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% block title %}{{ project.title }} · Projects · {{ project.owner }}{% endblock %} |
|
| 3 | + | {% block mainclass %} class="wide"{% endblock %} |
|
| 4 | + | {% block content %} |
|
| 5 | + | <div class="head"><h1>{{ project.title }} <small><a href="/projects/{{ project.owner }}">{{ project.owner }}'s projects</a> · #{{ project.number }}{% if !project.public %} · private{% endif %}{% if project.closed %} · closed{% endif %}</small></h1> |
|
| 6 | + | {% if !project.description.is_empty() %}<p class="mute">{{ project.description }}</p>{% endif %}</div> |
|
| 7 | + | {% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %} |
|
| 8 | + | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| 9 | + | <div class="board" role="list" aria-label="Columns"> |
|
| 10 | + | {% for col in columns %} |
|
| 11 | + | <section class="board-col" role="listitem" aria-labelledby="col-{{ col.column.id }}"> |
|
| 12 | + | <header><h2 id="col-{{ col.column.id }}">{{ col.column.name }} <span class="count-chip">{{ col.cards.len() }}</span></h2> |
|
| 13 | + | {% if can_edit %}<form class="tiny" method="post" action="/projects/{{ project.owner }}/{{ project.number }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="column" value="{{ col.column.id }}"><button name="action" value="left" title="Move the column left" aria-label="Move {{ col.column.name }} left">←</button><button name="action" value="right" title="Move the column right" aria-label="Move {{ col.column.name }} right">→</button></form>{% endif %}</header> |
|
| 14 | + | <ul class="cards"> |
|
| 15 | + | {% for c in col.cards %} |
|
| 16 | + | <li class="card-item{% if c.card.kind == "note" %} note{% endif %}"> |
|
| 17 | + | {% if c.card.kind == "note" %}<p>{{ c.title }}</p> |
|
| 18 | + | {% else %}<a href="{{ c.url }}">{{ c.title }}</a> |
|
| 19 | + | <p class="small mute"><span class="glyph" aria-hidden="true">{% if c.card.kind == "issue" %}◇{% else %}⇄{% endif %}</span> {{ c.reference }} · <span class="state-{{ c.state }}">{{ c.state }}</span></p>{% endif %} |
|
| 20 | + | {% if can_edit %} |
|
| 21 | + | <form class="tiny card-actions" method="post" action="/projects/{{ project.owner }}/{{ project.number }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="card" value="{{ c.card.id }}"> |
|
| 22 | + | <label class="visually-hidden" for="mv-{{ c.card.id }}">Column</label> |
|
| 23 | + | <select id="mv-{{ c.card.id }}" name="column">{% for o in columns %}<option value="{{ o.column.id }}"{% if o.column.id == col.column.id %} selected{% endif %}>{{ o.column.name }}</option>{% endfor %}</select> |
|
| 24 | + | <button name="action" value="move">move</button><button name="action" value="up" aria-label="Up">↑</button><button name="action" value="down" aria-label="Down">↓</button><button name="action" value="remove" aria-label="Remove from the project">✕</button> |
|
| 25 | + | </form>{% endif %} |
|
| 26 | + | </li> |
|
| 27 | + | {% endfor %} |
|
| 28 | + | </ul> |
|
| 29 | + | {% if can_edit %} |
|
| 30 | + | <details class="add-card"><summary class="small">add a card</summary> |
|
| 31 | + | <form method="post" action="/projects/{{ project.owner }}/{{ project.number }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="column" value="{{ col.column.id }}"><input type="hidden" name="action" value="add"> |
|
| 32 | + | <label for="ref-{{ col.column.id }}">Issue or proposal</label> |
|
| 33 | + | <div class="inline-fields"><select name="repo" aria-label="Repository">{% for r in repos %}<option>{{ r }}</option>{% endfor %}</select><input type="text" id="ref-{{ col.column.id }}" name="reference" placeholder="#12 or repo#12"></div> |
|
| 34 | + | <label for="note-{{ col.column.id }}">or a note</label><textarea id="note-{{ col.column.id }}" name="note" rows="2" maxlength="500"></textarea> |
|
| 35 | + | <button type="submit" class="small">ADD</button> |
|
| 36 | + | </form> |
|
| 37 | + | </details> |
|
| 38 | + | <details class="add-card"><summary class="small">column</summary> |
|
| 39 | + | <form method="post" action="/projects/{{ project.owner }}/{{ project.number }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="column" value="{{ col.column.id }}"> |
|
| 40 | + | <input type="text" name="name" value="{{ col.column.name }}" aria-label="Column name" maxlength="40"><button name="action" value="rename_column" class="small">RENAME</button> <button name="action" value="remove_column" class="small danger-btn" onclick="return confirm('Remove this column? Its cards move to the first column.')">REMOVE</button> |
|
| 41 | + | </form> |
|
| 42 | + | </details> |
|
| 43 | + | {% endif %} |
|
| 44 | + | </section> |
|
| 45 | + | {% endfor %} |
|
| 46 | + | {% if can_edit %} |
|
| 47 | + | <section class="board-col add-col"><form method="post" action="/projects/{{ project.owner }}/{{ project.number }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><label for="newcol">New column</label><input type="text" id="newcol" name="name" maxlength="40" required><button name="action" value="add_column" class="small">ADD COLUMN</button></form></section> |
|
| 48 | + | {% endif %} |
|
| 49 | + | </div> |
|
| 50 | + | {% if can_manage %} |
|
| 51 | + | <details class="panel"><summary>Settings</summary> |
|
| 52 | + | <form class="card" method="post" action="/projects/{{ project.owner }}/{{ project.number }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 53 | + | <label for="ptitle">Title</label><input type="text" id="ptitle" name="title" value="{{ project.title }}" maxlength="100"> |
|
| 54 | + | <label for="pdesc">Description</label><input type="text" id="pdesc" name="description" value="{{ project.description }}"> |
|
| 55 | + | <div class="choice"><label><input type="checkbox" name="private" value="1"{% if !project.public %} checked{% endif %}> private</label> <label><input type="checkbox" name="closed" value="1"{% if project.closed %} checked{% endif %}> closed (read-only)</label></div> |
|
| 56 | + | <button name="action" value="settings">SAVE</button> <button name="action" value="delete" class="danger-btn" onclick="return confirm('Delete this project? Issues and proposals are not touched.')">DELETE PROJECT</button> |
|
| 57 | + | </form></details> |
|
| 58 | + | {% endif %} |
|
| 59 | + | {% endblock %} |
added templates/projects.html +25 −0
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% block title %}Projects · {{ owner }}{% endblock %} |
|
| 3 | + | {% block content %} |
|
| 4 | + | <div class="head"><h1>Projects <small><a href="/{{ owner }}">{{ owner }}</a></small></h1></div> |
|
| 5 | + | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| 6 | + | {% 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.{% endif %}</p>{% else %} |
|
| 7 | + | <ul class="rows"> |
|
| 8 | + | {% for p in projects %} |
|
| 9 | + | <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 %} |
|
| 10 | + | <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> |
|
| 11 | + | {% endfor %} |
|
| 12 | + | </ul> |
|
| 13 | + | {% endif %} |
|
| 14 | + | {% if can_edit %} |
|
| 15 | + | <form class="card" method="post" action="/projects/{{ owner }}"> |
|
| 16 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 17 | + | <h2>New project</h2> |
|
| 18 | + | <label for="title">Title</label><input type="text" id="title" name="title" maxlength="100" required> |
|
| 19 | + | <label for="description">Description</label><input type="text" id="description" name="description"> |
|
| 20 | + | <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> |
|
| 21 | + | <button type="submit">CREATE</button> |
|
| 22 | + | <p class="small mute">It starts with the columns Backlog, Doing and Done.</p> |
|
| 23 | + | </form> |
|
| 24 | + | {% endif %} |
|
| 25 | + | {% endblock %} |
modified crates/hub/src/db/mod.rs +1 −0
| … | 6 unchanged lines |
||
| 7 | 7 | pub mod hooks; |
|
| 8 | 8 | pub mod issues; |
|
| 9 | 9 | pub mod notify; |
|
| 10 | + | pub mod projects; |
|
| 10 | 11 | pub mod proposals; |
|
| 11 | 12 | pub mod releases; |
|
| 12 | 13 | pub mod repos; |
|
| … | 25 unchanged lines |
modified crates/hub/src/repos/names.rs +1 −0
| … | 29 unchanged lines |
||
| 30 | 30 | "health", |
|
| 31 | 31 | "avatar", |
|
| 32 | 32 | "help", |
|
| 33 | + | "projects", |
|
| 33 | 34 | "assets", |
|
| 34 | 35 | ".well-known", |
|
| 35 | 36 | ]; |
|
| … | 62 unchanged lines |
modified crates/hub/src/web/issues.rs +4 −0
| … | 301 unchanged lines |
||
| 302 | 302 | subscribed: bool, |
|
| 303 | 303 | can_edit: bool, |
|
| 304 | 304 | can_triage: bool, |
|
| 305 | + | /// Projects this issue is on that the viewer may see, with the column. |
|
| 306 | + | projects: Vec<(crate::db::projects::Project, String)>, |
|
| 305 | 307 | notice: Option<String>, |
|
| 306 | 308 | error: Option<String>, |
|
| 307 | 309 | } |
|
| … | 50 unchanged lines |
||
| 358 | 360 | }; |
|
| 359 | 361 | let can_triage = role >= Role::Triage; |
|
| 360 | 362 | let can_edit = me.is_some_and(|a| Some(a.id) == i.author_id) || can_triage; |
|
| 363 | + | let projects = super::projects::placements_for(app, viewer, &row, i.number).await; |
|
| 361 | 364 | Ok(Page(IssuePage { |
|
| 362 | 365 | shell, |
|
| 363 | 366 | repo: RepoShell::new(app, &row, "issues").with_role(role), |
|
| … | 9 unchanged lines |
||
| 373 | 376 | subscribed, |
|
| 374 | 377 | can_edit, |
|
| 375 | 378 | can_triage, |
|
| 379 | + | projects, |
|
| 376 | 380 | notice, |
|
| 377 | 381 | error, |
|
| 378 | 382 | })) |
|
| … | 576 unchanged lines |
modified crates/hub/src/web/mod.rs +3 −0
| … | 14 unchanged lines |
||
| 15 | 15 | mod inbox; |
|
| 16 | 16 | pub mod issues; |
|
| 17 | 17 | mod orgs; |
|
| 18 | + | mod projects; |
|
| 18 | 19 | pub mod proposals; |
|
| 19 | 20 | mod recovery; |
|
| 20 | 21 | mod refs; |
|
| … | 70 unchanged lines |
||
| 91 | 92 | .route("/{owner}/{name}/changes/{r}", get(history::history)) |
|
| 92 | 93 | .route("/{owner}/{name}/change/{hash}", get(history::change)) |
|
| 93 | 94 | .route("/help", get(help::help)) |
|
| 95 | + | .route("/projects/{owner}", get(projects::list).post(projects::create)) |
|
| 96 | + | .route("/projects/{owner}/{number}", get(projects::show).post(projects::act)) |
|
| 94 | 97 | .route("/notifications/thread", post(inbox::thread)) |
|
| 95 | 98 | .route("/notifications/open/{id}", get(inbox::open)) |
|
| 96 | 99 | .route("/login/reset", get(recovery::request_form).post(recovery::request)) |
|
| … | 472 unchanged lines |
modified crates/hub/src/web/proposals.rs +4 −0
| … | 580 unchanged lines |
||
| 581 | 581 | all_labels: Vec<db::issues::Label>, |
|
| 582 | 582 | checks: Vec<db::hooks::Check>, |
|
| 583 | 583 | required_checks: Vec<String>, |
|
| 584 | + | /// Projects this proposal is on that the viewer may see, with the column. |
|
| 585 | + | projects: Vec<(db::projects::Project, String)>, |
|
| 584 | 586 | } |
|
| 585 | 587 | ||
| 586 | 588 | #[derive(Deserialize)] |
|
| … | 77 unchanged lines |
||
| 664 | 666 | "changes" => format!("{} changes", p.source_changes().len()), |
|
| 665 | 667 | _ => p.source_bookmark.clone(), |
|
| 666 | 668 | }; |
|
| 669 | + | let projects = super::projects::placements_for(app, viewer, &row, p.number).await; |
|
| 667 | 670 | Ok(Page(ProposalPage { |
|
| 671 | + | projects, |
|
| 668 | 672 | shell, |
|
| 669 | 673 | repo: RepoShell::new(app, &row, "proposals").with_role(role), |
|
| 670 | 674 | body_html: super::issues::markdown(app, row.id, &format!("/{owner}/{name}"), &p.body).await, |
|
| … | 498 unchanged lines |
modified crates/hub/tests/issues.rs +80 −0
| … | 705 unchanged lines |
||
| 706 | 706 | let stable = get(app, "/andy/demo/proposals?q=target:stable", Some(&andy)).await.body; |
|
| 707 | 707 | assert!(stable.contains("Release prep") && !stable.contains("New theme")); |
|
| 708 | 708 | } |
|
| 709 | + | ||
| 710 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 711 | + | async fn project_boards() { |
|
| 712 | + | let h = hub().await; |
|
| 713 | + | let app = &h.app; |
|
| 714 | + | let andy = register(app, "andy").await; |
|
| 715 | + | let bob = register(app, "bob").await; |
|
| 716 | + | for (name, vis) in [("demo", hub_api::Visibility::Public), ("secret", hub_api::Visibility::Private)] { |
|
| 717 | + | app.repos.create(&RepoName::parse(&format!("andy/{name}")).unwrap(), None).unwrap(); |
|
| 718 | + | let row = db::repos::create(&app.db, "andy", name, "", vis).await.unwrap(); |
|
| 719 | + | db::issues::create( |
|
| 720 | + | &app.db, |
|
| 721 | + | db::issues::NewIssue { repo_id: row.id, title: &format!("{name} issue"), body: "", author_id: None, author: "andy" }, |
|
| 722 | + | ) |
|
| 723 | + | .await |
|
| 724 | + | .unwrap(); |
|
| 725 | + | } |
|
| 726 | + | let csrf = csrf_of(&get(app, "/projects/andy", Some(&andy)).await.body); |
|
| 727 | + | let r = post(app, "/projects/andy", Some(&andy), &format!("_csrf={csrf}&title=Roadmap&description=Next+steps")).await; |
|
| 728 | + | assert_eq!(r.location.as_deref(), Some("/projects/andy/1"), "{}", r.body); |
|
| 729 | + | let board = get(app, "/projects/andy/1", Some(&andy)).await.body; |
|
| 730 | + | for col in ["Backlog", "Doing", "Done"] { |
|
| 731 | + | assert!(board.contains(&format!(">{col} <span")), "{col}"); |
|
| 732 | + | } |
|
| 733 | + | let cols: Vec<i64> = sqlx::query_scalar("SELECT id FROM project_columns ORDER BY position").fetch_all(&app.db).await.unwrap(); |
|
| 734 | + | let act = |form: String| { |
|
| 735 | + | let andy = andy.clone(); |
|
| 736 | + | async move { post(app, "/projects/andy/1", Some(&andy), &form).await } |
|
| 737 | + | }; |
|
| 738 | + | // Cards: by reference, by repository and number, a note; duplicates and strangers refused. |
|
| 739 | + | assert_eq!( |
|
| 740 | + | act(format!("_csrf={csrf}&action=add&column={}&reference=demo%231", cols[0])).await.location.as_deref(), |
|
| 741 | + | Some("/projects/andy/1") |
|
| 742 | + | ); |
|
| 743 | + | act(format!("_csrf={csrf}&action=add&column={}&repo=secret&reference=%231", cols[0])).await; |
|
| 744 | + | act(format!("_csrf={csrf}&action=add&column={}¬e=Write+the+docs", cols[1])).await; |
|
| 745 | + | assert!(act(format!("_csrf={csrf}&action=add&column={}&reference=demo%231", cols[1])).await.body.contains("already on this project")); |
|
| 746 | + | assert!(act(format!("_csrf={csrf}&action=add&column={}&reference=demo%2399", cols[0])).await.body.contains("no issue or proposal #99")); |
|
| 747 | + | assert!(act(format!("_csrf={csrf}&action=add&column={}&reference=nothere%231", cols[0])).await.body.contains("no repository nothere")); |
|
| 748 | + | // Move demo#1 to Done. |
|
| 749 | + | let demo_card: i64 = |
|
| 750 | + | sqlx::query_scalar("SELECT c.id FROM project_cards c JOIN repositories r ON r.id = c.repo_id WHERE r.name = 'demo'") |
|
| 751 | + | .fetch_one(&app.db) |
|
| 752 | + | .await |
|
| 753 | + | .unwrap(); |
|
| 754 | + | act(format!("_csrf={csrf}&action=move&card={demo_card}&column={}", cols[2])).await; |
|
| 755 | + | let issue = get(app, "/andy/demo/issue/1", None).await.body; |
|
| 756 | + | assert!(issue.contains("href=\"/projects/andy/1\">Roadmap</a> <span class=\"mute\">· Done"), "the issue shows where it is"); |
|
| 757 | + | ||
| 758 | + | // Readers see public cards, never those of repositories they cannot read. |
|
| 759 | + | let anon = get(app, "/projects/andy/1", None).await.body; |
|
| 760 | + | assert!(anon.contains("demo issue") && anon.contains("Write the docs") && !anon.contains("secret issue"), "{anon}"); |
|
| 761 | + | assert!(!anon.contains("name=\"action\""), "and cannot change anything"); |
|
| 762 | + | assert!(get(app, "/projects/andy/1", Some(&andy)).await.body.contains("secret issue")); |
|
| 763 | + | // Only the owner changes it; a private project is invisible to others. |
|
| 764 | + | let csrf_b = csrf_of(&get(app, "/settings", Some(&bob)).await.body); |
|
| 765 | + | assert_eq!( |
|
| 766 | + | post(app, "/projects/andy/1", Some(&bob), &format!("_csrf={csrf_b}&action=remove&card={demo_card}")).await.status, |
|
| 767 | + | StatusCode::NOT_FOUND |
|
| 768 | + | ); |
|
| 769 | + | act(format!("_csrf={csrf}&action=settings&title=Roadmap&description=&private=1")).await; |
|
| 770 | + | assert_eq!(get(app, "/projects/andy/1", Some(&bob)).await.status, StatusCode::NOT_FOUND); |
|
| 771 | + | assert!(!get(app, "/projects/andy", None).await.body.contains("Roadmap"), "not even listed"); |
|
| 772 | + | assert!(!get(app, "/andy/demo/issue/1", None).await.body.contains("Roadmap"), "nor on the issue"); |
|
| 773 | + | ||
| 774 | + | // Columns: removing one moves its cards to the first column; the last one stays. |
|
| 775 | + | act(format!("_csrf={csrf}&action=remove_column&column={}", cols[1])).await; |
|
| 776 | + | let note_col: i64 = sqlx::query_scalar("SELECT column_id FROM project_cards WHERE kind = 'note'").fetch_one(&app.db).await.unwrap(); |
|
| 777 | + | assert_eq!(note_col, cols[0]); |
|
| 778 | + | act(format!("_csrf={csrf}&action=remove_column&column={}", cols[2])).await; |
|
| 779 | + | assert!(act(format!("_csrf={csrf}&action=remove_column&column={}", cols[0])).await.body.contains("at least one column")); |
|
| 780 | + | // Closed projects are read-only until reopened; then deleted, the issues remain. |
|
| 781 | + | act(format!("_csrf={csrf}&action=settings&title=Roadmap&closed=1")).await; |
|
| 782 | + | assert!(act(format!("_csrf={csrf}&action=add&column={}¬e=late", cols[0])).await.body.contains("This project is closed")); |
|
| 783 | + | let r = act(format!("_csrf={csrf}&action=delete")).await; |
|
| 784 | + | assert_eq!(r.location.as_deref(), Some("/projects/andy")); |
|
| 785 | + | assert_eq!(get(app, "/projects/andy/1", Some(&andy)).await.status, StatusCode::NOT_FOUND); |
|
| 786 | + | assert_eq!(get(app, "/andy/demo/issue/1", None).await.status, StatusCode::OK); |
|
| 787 | + | assert_eq!(get(app, "/projects/nobody", None).await.status, StatusCode::NOT_FOUND); |
|
| 788 | + | } |
modified docs/spec.md +9 −0
| … | 255 unchanged lines |
||
| 256 | 256 | | `/{owner}/{name}/edit/{ref}/{path}`, `/new/{ref}/{path}` | Web edit (§6.9) | |
|
| 257 | 257 | | `/settings/*` | Profile, security (password, TOTP, sessions), keys (linked identities), tokens, notifications, theme | |
|
| 258 | 258 | | `/notifications` | Inbox (§10) | |
|
| 259 | + | | `/projects/{owner}`, `/projects/{owner}/{n}` | Projects of a person or organisation (§8.2) | |
|
| 259 | 260 | | `/search` | §11 | |
|
| 260 | 261 | | `/help` | Getting started with this hub's own address, remote name and certificate filled in (identity, pushing, cloning, proposals, delegating to the hub, pipelines and stamps), the keyboard, and a glossary for people coming from GitHub. Linked from the header band, the footer and the keyboard sheet | |
|
| 261 | 262 | | `/admin/*` | Site: accounts, registration mode, storage, jobs, audit log | |
|
| … | 109 unchanged lines |
||
| 371 | 372 | Cross references: `#12` links an issue or proposal; `owner/name#12` across repositories; a 12-hex-or-longer token links a change when it resolves; `closes #12` in a change message closes the issue when the change lands on the default bookmark. Every reference creates a timeline entry on the target. |
|
| 372 | 373 | ||
| 373 | 374 | Import from GitHub or GitLab is a 1.x item. |
|
| 375 | + | ||
| 376 | + | ### 8.2 Projects |
|
| 377 | + | ||
| 378 | + | A **project** is a board that plans work across the repositories of one owner (a person or an organisation): a number per owner, a title, a description, public or private, open or closed, and ordered **columns** (new projects start with Backlog, Doing and Done). A column holds ordered **cards**; a card is an issue or proposal of one of the owner's repositories, or a note. |
|
| 379 | + | ||
| 380 | + | - Who: anyone may see a public project; a private one only its owner (for an organisation, its members) and site administrators. The owner, an organisation's members and administrators may change it. A card for an issue or proposal whose repository the viewer cannot read is not shown at all. |
|
| 381 | + | - The board works without JavaScript: each card has a column picker and up and down buttons; cards are added by reference (`#12` in a named repository, or `repo#12`) or as a note; an issue or proposal is on a project at most once. Issue and proposal pages list the projects they are on, with their column. |
|
| 382 | + | - Columns can be added, renamed, moved and removed (removing one moves its cards to the first column). Closing a project keeps it read-only. |
|
| 374 | 383 | ||
| 375 | 384 | ## 9. Releases and tags |
|
| 376 | 385 | ||
| … | 191 unchanged lines |
modified templates/issue.html +2 −0
| … | 52 unchanged lines |
||
| 53 | 53 | <h3>Milestone</h3> |
|
| 54 | 54 | {% if let Some(ms) = milestone %}<p><a href="{{ repo.url }}/milestones">{{ ms.title }}</a></p>{% else %}<p class="mute small">None.</p>{% endif %} |
|
| 55 | 55 | {% 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 %} |
|
| 56 | + | <h3>Projects</h3> |
|
| 57 | + | {% if projects.is_empty() %}<p class="mute small">None. <a href="/projects/{{ repo.owner }}">{{ repo.owner }}'s projects</a></p>{% else %}<ul class="plain small">{% for (p, col) in projects %}<li><a href="/projects/{{ p.owner }}/{{ p.number }}">{{ p.title }}</a> <span class="mute">· {{ col }}</span></li>{% endfor %}</ul>{% endif %} |
|
| 56 | 58 | {% if can_triage %} |
|
| 57 | 59 | <h3>Moderation</h3> |
|
| 58 | 60 | <form class="tiny-form" method="post" action="{{ repo.url }}/issue/{{ i.number }}/state"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| … | 9 unchanged lines |
modified templates/org.html +1 −0
| … | 10 unchanged lines |
||
| 11 | 11 | <h3>Members <small>{{ members.len() }}</small></h3> |
|
| 12 | 12 | <ul class="plain">{% for (m, role) in members %}<li><img class="avatar xs" src="/avatar/{{ m.username }}" alt=""> <a href="/{{ m.username }}">{{ m.username }}</a>{% if role == "owner" %} <span class="small mute">owner</span>{% endif %}</li>{% endfor %}</ul> |
|
| 13 | 13 | {% if !teams.is_empty() %} |
|
| 14 | + | <p><a class="go small" href="/projects/{{ org.name }}">Projects</a></p> |
|
| 14 | 15 | <h3>Teams</h3> |
|
| 15 | 16 | <ul class="plain">{% for (t, n) in teams %}<li>{{ t.name }} <span class="small mute">{{ n|plural("member", "members") }}</span></li>{% endfor %}</ul> |
|
| 16 | 17 | {% endif %} |
|
| … | 9 unchanged lines |
modified templates/profile.html +1 −0
| … | 13 unchanged lines |
||
| 14 | 14 | </ul> |
|
| 15 | 15 | {% if is_me %}<a class="go small" href="/settings">edit profile</a>{% endif %} |
|
| 16 | 16 | {% if !orgs.is_empty() %} |
|
| 17 | + | <p><a class="go small" href="/projects/{{ account.username }}">Projects</a></p> |
|
| 17 | 18 | <h3>Organisations</h3> |
|
| 18 | 19 | <ul class="plain">{% for (o, role) in orgs %}<li><img class="avatar xs" src="/avatar/{{ o.name }}" alt=""> <a href="/{{ o.name }}">{{ o.name }}</a>{% if role == "owner" %} <span class="small mute">owner</span>{% endif %}</li>{% endfor %}</ul> |
|
| 19 | 20 | {% endif %} |
|
| … | 12 unchanged lines |
modified templates/proposal.html +2 −0
| … | 115 unchanged lines |
||
| 116 | 116 | <h3>Labels</h3> |
|
| 117 | 117 | {% if labels.is_empty() %}<p class="mute small">None.</p>{% else %}<p>{% for l in labels %}<span class="label" style="--c: {{ l.colour }}">{{ l.name }}</span> {% endfor %}</p>{% endif %} |
|
| 118 | 118 | {% if repo.can(crate::auth::Access::Write) && !all_labels.is_empty() %}<form class="tiny-form" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/labels"><input type="hidden" name="_csrf" value="{{ shell.csrf }}">{% 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 %}<button type="submit" class="small">SET</button></form>{% endif %} |
|
| 119 | + | <h3>Projects</h3> |
|
| 120 | + | {% 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></li>{% endfor %}</ul>{% endif %} |
|
| 119 | 121 | <h3>Checks</h3> |
|
| 120 | 122 | <div id="checks"> |
|
| 121 | 123 | {% if checks.is_empty() %}<p class="mute small">None reported on the projected state{% if !required_checks.is_empty() %}; required: {{ required_checks.join(", ") }}{% endif %}.</p>{% else %} |
|
| … | 31 unchanged lines |
modified templates/style.css +19 −0
| … | 489 unchanged lines |
||
| 490 | 490 | header .find { display: none; } |
|
| 491 | 491 | } |
|
| 492 | 492 | @media (prefers-reduced-motion: reduce) { * { scroll-behavior: auto !important; transition: none !important; } } |
|
| 493 | + | ||
| 494 | + | /* Projects (hub spec §8.2). */ |
|
| 495 | + | .board { display: flex; gap: 12px; align-items: flex-start; overflow-x: auto; padding-bottom: 8px; } |
|
| 496 | + | .board-col { flex: 0 0 290px; background: var(--soft); border: 1px solid var(--line); padding: 8px; } |
|
| 497 | + | .board-col > header { display: flex; justify-content: space-between; align-items: center; gap: 6px; } |
|
| 498 | + | .board-col h2 { font-size: 14px; margin: 2px 0 8px; } |
|
| 499 | + | .board-col ul.cards { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; min-height: 12px; } |
|
| 500 | + | .card-item { background: var(--panel); border: 1px solid var(--line); padding: 8px 10px; } |
|
| 501 | + | .card-item.note { border-left: 3px solid var(--warn); } |
|
| 502 | + | .card-item p { margin: 4px 0 0; } |
|
| 503 | + | .card-item .card-actions { margin-top: 6px; display: flex; gap: 4px; flex-wrap: wrap; align-items: center; } |
|
| 504 | + | .card-item .card-actions select { width: auto; margin: 0; padding: 1px 4px; font-size: 12px; } |
|
| 505 | + | .board .add-card { margin-top: 8px; } |
|
| 506 | + | .board .add-card form { display: flex; flex-direction: column; gap: 4px; margin-top: 6px; } |
|
| 507 | + | .board .inline-fields { display: flex; gap: 4px; } |
|
| 508 | + | .board .inline-fields select { width: 40%; } |
|
| 509 | + | .board .add-col form { display: flex; flex-direction: column; gap: 6px; } |
|
| 510 | + | .state-open { color: var(--ok); } .state-closed, .state-landed { color: var(--acc); } |
|
| 511 | + | .visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } |