Star a drop (refs #280) c419455a82dd
A signed-in visitor stars a drop, or takes the star back, from its page, which shows the count; drops share the repositories' stars table and never join a list of starred repositories.
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 7bbbb9f96f8e
- When
- · 2026-10-21 03:05 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
c419455a82dd6399649a6bf25e2ea61ee2e3c9b7562b50c768f73b766a1e0c5d- Depends on
- 166f6e9bf4f1 8bf3010760d7 93be9ce4653e a7e6e8e6094c ab2d8b40f9f4 adbc38e7ea09 b5668108487f d23464c0ac60
- Depended on by
- 8ac2aa4ca6d1 ca0c443c30cf
- In bookmarks
- api-unknown-fields diffusion-pin-bookmark draft-ready main proposal-list-fast
- In tags
- 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
- 11 atoms, 0 tree operations, 6 files
Browse the tree at this change
modified CHANGELOG.md +1 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **Star a drop (#280):** a signed-in visitor stars a drop, or takes the star back, from its page, which shows how many it has. |
|
| 7 | 8 | ## 0.7.0 (2026-10-21) |
|
| 8 | 9 | ||
| 9 | 10 | - **Fork a drop (#280):** a signed-in visitor's FORK makes a drop of their own from someone else's, with its files, history, title and secrecy; the fork's page says which drop it came from. |
|
| … | 281 unchanged lines |
modified crates/hub/src/web/drops.rs +27 −0
| … | 62 unchanged lines |
||
| 63 | 63 | can_delete: bool, |
|
| 64 | 64 | /// Signed in and not its owner: may fork it (#280). |
|
| 65 | 65 | can_fork: bool, |
|
| 66 | + | /// Its stars, and whether the viewer gave one (#280). |
|
| 67 | + | stars: i64, |
|
| 68 | + | starred: bool, |
|
| 69 | + | signed_in: bool, |
|
| 66 | 70 | /// The drop it was forked from, if it still exists: its address. |
|
| 67 | 71 | forked_from: Option<String>, |
|
| 68 | 72 | clone_url: String, |
|
| … | 117 unchanged lines |
||
| 186 | 190 | Some(id) => db::repos::get_by_id(&app.db, id).await.ok().flatten().filter(|r| r.is_drop()).map(|r| drops::url_of(&r)), |
|
| 187 | 191 | None => None, |
|
| 188 | 192 | }; |
|
| 193 | + | let stars = db::repos::counts(&app.db, row.id).await.map(|(s, _)| s).unwrap_or(0); |
|
| 194 | + | let starred = match viewer.account() { |
|
| 195 | + | Some(a) => db::repos::starred(&app.db, a.id, row.id).await.unwrap_or(false), |
|
| 196 | + | None => false, |
|
| 197 | + | }; |
|
| 189 | 198 | Ok(Page(DropPage { |
|
| 199 | + | stars, |
|
| 200 | + | starred, |
|
| 201 | + | signed_in: viewer.account().is_some(), |
|
| 190 | 202 | can_fork, |
|
| 191 | 203 | forked_from, |
|
| 192 | 204 | repo: RepoShell::new(app, &row, "code").with_role(role), |
|
| … | 243 unchanged lines |
||
| 436 | 448 | let row = writable(&app, &shell, &viewer, &owner, &id, true).await?; |
|
| 437 | 449 | drops::delete(&app, &me.account, &row).await.map_err(|e| Error::bad(shell.clone(), e.to_string()))?; |
|
| 438 | 450 | Ok(Redirect::to(&format!("/{}/drops?deleted=1", row.owner)).into_response()) |
|
| 451 | + | } |
|
| 452 | + | ||
| 453 | + | /// `POST /{owner}/drops/{id}/star` (#280): star it, or take the star back. |
|
| 454 | + | pub async fn star( |
|
| 455 | + | State(app): State<Shared>, |
|
| 456 | + | viewer: Viewer, |
|
| 457 | + | Path((owner, id)): Path<(String, String)>, |
|
| 458 | + | Form(f): Form<DeleteForm>, |
|
| 459 | + | ) -> Result<Response, Error> { |
|
| 460 | + | let shell = Shell::new(&app, &format!("/{owner}/drops/{id}"), &viewer); |
|
| 461 | + | let me = require(&viewer, &shell)?; |
|
| 462 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 463 | + | let row = lookup(&app, &shell, &viewer, &owner, &id).await?; |
|
| 464 | + | db::repos::toggle_star(&app.db, me.account.id, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 465 | + | Ok(Redirect::to(&drops::url_of(&row)).into_response()) |
|
| 439 | 466 | } |
|
| 440 | 467 | ||
| 441 | 468 | /// `POST /{owner}/drops/{id}/fork` (#280): a copy of the drop, history and |
|
| … | 63 unchanged lines |
modified crates/hub/src/web/mod.rs +1 −0
| … | 195 unchanged lines |
||
| 196 | 196 | .route("/{owner}/drops/{id}/edit", get(drops::edit_form).post(drops::save)) |
|
| 197 | 197 | .route("/{owner}/drops/{id}/delete", post(drops::delete)) |
|
| 198 | 198 | .route("/{owner}/drops/{id}/fork", post(drops::fork)) |
|
| 199 | + | .route("/{owner}/drops/{id}/star", post(drops::star)) |
|
| 199 | 200 | .route("/{owner}/drops/{id}/changes/{r}", get(drops::changes)) |
|
| 200 | 201 | .route("/{owner}/drops/{id}/change/{hash}", get(drops::change)) |
|
| 201 | 202 | .route("/{owner}/drops/{id}/raw/{r}/{*path}", get(drops::raw)) |
|
| … | 702 unchanged lines |
modified crates/hub/tests/drops.rs +18 −0
| … | 477 unchanged lines |
||
| 478 | 478 | assert_eq!(get(app, &format!("{fork}/raw/main/a.txt"), None).await.body, "a"); |
|
| 479 | 479 | let history = get(app, &format!("{fork}/changes/main"), Some(&andy)).await.body; |
|
| 480 | 480 | assert!(history.contains("Create Notes"), "the source's change: {history}"); |
|
| 481 | + | ||
| 482 | + | // Stars: one per account, taken back by a second press. |
|
| 483 | + | let page = get(app, &source, Some(&andy)).await.body; |
|
| 484 | + | assert!(page.contains("☆ STAR 0"), "{page}"); |
|
| 485 | + | let star = |cookie: String| { |
|
| 486 | + | let (app, source) = (app.clone(), source.clone()); |
|
| 487 | + | async move { |
|
| 488 | + | let csrf = csrf_of(&get(&app, &source, Some(&cookie)).await.body); |
|
| 489 | + | let r = post(&app, &format!("{source}/star"), Some(&cookie), &format!("_csrf={csrf}")).await; |
|
| 490 | + | assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
|
| 491 | + | } |
|
| 492 | + | }; |
|
| 493 | + | star(andy.clone()).await; |
|
| 494 | + | star(carol.clone()).await; |
|
| 495 | + | assert!(get(app, &source, Some(&andy)).await.body.contains("★ STARRED 2")); |
|
| 496 | + | star(andy.clone()).await; |
|
| 497 | + | assert!(get(app, &source, Some(&andy)).await.body.contains("☆ STAR 1")); |
|
| 498 | + | assert!(get(app, &source, None).await.body.contains("1 star"), "the signed out see the count"); |
|
| 481 | 499 | } |
|
| 482 | 500 | ||
| 483 | 501 | #[tokio::test(flavor = "multi_thread")] |
|
| … | 27 unchanged lines |
modified docs/spec.md +1 −0
| … | 178 unchanged lines |
||
| 179 | 179 | - **Push protection** (§15.4) applies to a drop's pushes as to any repository's, with no fingerprint to allow (a drop has no settings), and the hub runs the same rules over what it is asked to commit from the web or the API, refusing before anything is stored. |
|
| 180 | 180 | - **Deleting** moves the repository to the trash under its exclusive lock (oxygenhub#249) and removes the row; the address stops answering. Audited as `drop.created`, `drop.edited`, `drop.visibility` and `drop.deleted`. |
|
| 181 | 181 | - **Forks** (#280): a signed-in visitor who is not the owner may fork a drop they can read (`POST /{owner}/drops/{id}/fork`, the page's FORK): a new drop of theirs whose store is a fork of the source (objects linked, bookmarks copied, as a repository fork), with its title, description and secrecy, `forked_from` naming the source; its page says "forked from" with the source's address while it exists. Audited as `drop.forked`. |
|
| 182 | + | - **Stars** (#280): a signed-in visitor stars a drop, or takes the star back, from its page (`POST /{owner}/drops/{id}/star`, the repositories' `stars` table); the page shows the count, which never joins a list of starred repositories. |
|
| 182 | 183 | - **API:** `GET /api/v1/drops` (yours, `repo:read`), `POST /api/v1/drops` (`NewDrop`, `repo:write`), `GET /api/v1/users/{name}/drops` (someone's public drops), `GET /api/v1/drops/{id}` (anyone with the id; each text file's content up to 512 KiB), `PATCH /api/v1/drops/{id}` (`DropEdit`: title, description, `secret`, and files to write or, with `content: null`, remove; others are kept) and `DELETE /api/v1/drops/{id}`. `oh drop create|list|view|delete` (docs/oh.md). |
|
| 183 | 184 | - **Later:** forks, stars, comments, embedding a drop in another page, and drops in backups. |
|
| 184 | 185 | ||
| … | 705 unchanged lines |
modified templates/drop.html +2 −2
| … | 7 unchanged lines |
||
| 8 | 8 | <div class="drop-id"> |
|
| 9 | 9 | <h1>{{ title }}{% if !at_default %} <small>at {{ target }}</small>{% endif %}</h1> |
|
| 10 | 10 | {% if !description.is_empty() %}<p class="drop-desc">{{ description }}</p>{% endif %} |
|
| 11 | - | <p class="small mute">A drop by <a href="/{{ repo.owner }}">{{ repo.owner }}</a> · {{ files.len() }} file{% if files.len() != 1 %}s{% endif %} · created <time title="{{ created_ms|when_i }}">{{ created_ms|ago_i }}</time> · updated <time title="{{ updated_ms|when_i }}">{{ updated_ms|ago_i }}</time> · <a href="{{ repo.url }}/changes/{{ repo.default_bookmark }}">history</a>{% if let Some(f) = forked_from %} · forked from <a href="{{ f }}">{{ f }}</a>{% endif %}</p> |
|
| 11 | + | <p class="small mute">A drop by <a href="/{{ repo.owner }}">{{ repo.owner }}</a> · {{ files.len() }} file{% if files.len() != 1 %}s{% endif %} · created <time title="{{ created_ms|when_i }}">{{ created_ms|ago_i }}</time> · updated <time title="{{ updated_ms|when_i }}">{{ updated_ms|ago_i }}</time> · <a href="{{ repo.url }}/changes/{{ repo.default_bookmark }}">history</a>{% if !signed_in && stars > 0 %} · {{ stars }} star{% if stars != 1 %}s{% endif %}{% endif %}{% if let Some(f) = forked_from %} · forked from <a href="{{ f }}">{{ f }}</a>{% endif %}</p> |
|
| 12 | 12 | </div> |
|
| 13 | - | {% if can_edit || can_delete || can_fork %}<div class="drop-actions">{% if can_fork %}<form class="inline" method="post" action="{{ repo.url }}/fork"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit" title="A copy of this drop, history and all, as a drop of yours">FORK</button></form>{% endif %}{% if can_edit %}<a class="go" href="{{ repo.url }}/edit">EDIT</a>{% endif %}{% if can_delete %}<form class="inline" method="post" action="{{ repo.url }}/delete" data-confirm="Delete this drop and its history? Its address stops working."><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit" class="danger-btn">DELETE</button></form>{% endif %}</div>{% endif %} |
|
| 13 | + | {% if signed_in || can_edit || can_delete || can_fork %}<div class="drop-actions">{% if signed_in %}<form class="inline" method="post" action="{{ repo.url }}/star"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit" title="{% if starred %}Take your star back{% else %}Star this drop{% endif %}" aria-pressed="{% if starred %}true{% else %}false{% endif %}">{% if starred %}★ STARRED{% else %}☆ STAR{% endif %} {{ stars }}</button></form>{% endif %}{% if can_fork %}<form class="inline" method="post" action="{{ repo.url }}/fork"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit" title="A copy of this drop, history and all, as a drop of yours">FORK</button></form>{% endif %}{% if can_edit %}<a class="go" href="{{ repo.url }}/edit">EDIT</a>{% endif %}{% if can_delete %}<form class="inline" method="post" action="{{ repo.url }}/delete" data-confirm="Delete this drop and its history? Its address stops working."><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit" class="danger-btn">DELETE</button></form>{% endif %}</div>{% endif %} |
|
| 14 | 14 | </div> |
|
| 15 | 15 | {% if secret %}<div class="strip">Secret: this drop is listed nowhere, but anyone with its address can read it.</div>{% endif %} |
|
| 16 | 16 | <div class="drop-clone"><label for="clone">Clone</label><input type="text" id="clone" class="code" readonly value="oxy clone {{ clone_url }}" data-select><button type="button" class="small" data-copy="oxy clone {{ clone_url }}">copy</button></div> |
|
| … | 21 unchanged lines |