Comments on drops (refs #280) ca0c443c30cf
Signed-in readers comment on a drop in Markdown (drop_comments, migration 0092, at most 64 KiB), shown under its files; the drop's owner is told; the author, the owner or a site administrator deletes one.
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key d6f7fec46fbd
- When
- · 2026-10-21 03:41 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
ca0c443c30cfa854e929cc374594a0e4a80f7fa24e0e42e4701734a6d6ef72fb- Depends on
- 166f6e9bf4f1 18e381ea88c2 3b1bc27fe805 8bf3010760d7 93be9ce4653e a7e6e8e6094c adbc38e7ea09 b5668108487f b91e2d81b3f1 c419455a82dd d23464c0ac60
- Depended on by
- 04f9c984439e 8ac2aa4ca6d1
- 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
- 16 atoms, 2 tree operations, 11 files
Browse the tree at this change
added crates/hub/migrations-pg/0092_drop_comments.sql +11 −0
| 1 | + | -- Comments on a drop (oxygenhub#280): its readers', in Markdown; the |
|
| 2 | + | -- author, the drop's owner or a site administrator deletes one. |
|
| 3 | + | CREATE TABLE drop_comments ( |
|
| 4 | + | id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, |
|
| 5 | + | repo_id BIGINT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, |
|
| 6 | + | author_id BIGINT REFERENCES accounts(id) ON DELETE SET NULL, |
|
| 7 | + | author TEXT NOT NULL DEFAULT '', |
|
| 8 | + | body TEXT NOT NULL, |
|
| 9 | + | created_ms BIGINT NOT NULL |
|
| 10 | + | ); |
|
| 11 | + | CREATE INDEX drop_comments_by_drop ON drop_comments (repo_id, created_ms); |
added crates/hub/migrations/0092_drop_comments.sql +11 −0
| 1 | + | -- Comments on a drop (oxygenhub#280): its readers', in Markdown; the |
|
| 2 | + | -- author, the drop's owner or a site administrator deletes one. |
|
| 3 | + | CREATE TABLE drop_comments ( |
|
| 4 | + | id INTEGER PRIMARY KEY, |
|
| 5 | + | repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, |
|
| 6 | + | author_id INTEGER REFERENCES accounts(id) ON DELETE SET NULL, |
|
| 7 | + | author TEXT NOT NULL DEFAULT '', |
|
| 8 | + | body TEXT NOT NULL, |
|
| 9 | + | created_ms INTEGER NOT NULL |
|
| 10 | + | ); |
|
| 11 | + | CREATE INDEX drop_comments_by_drop ON drop_comments (repo_id, created_ms); |
modified CHANGELOG.md +1 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **Comments on drops (#280):** a drop's page has comments: signed-in readers write them in Markdown, the drop's owner is told, and the author, the owner or a site administrator deletes one. |
|
| 7 | 8 | - **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. |
|
| 8 | 9 | ## 0.7.0 (2026-10-21) |
|
| 9 | 10 | ||
| … | 282 unchanged lines |
modified crates/hub/src/db/repos.rs +36 −0
| … | 182 unchanged lines |
||
| 183 | 183 | .await |
|
| 184 | 184 | } |
|
| 185 | 185 | ||
| 186 | + | /// A comment on a drop (#280). |
|
| 187 | + | #[derive(Clone, Debug, FromRow)] |
|
| 188 | + | pub struct DropComment { |
|
| 189 | + | pub id: i64, |
|
| 190 | + | pub repo_id: i64, |
|
| 191 | + | pub author_id: Option<i64>, |
|
| 192 | + | pub author: String, |
|
| 193 | + | pub body: String, |
|
| 194 | + | pub created_ms: i64, |
|
| 195 | + | } |
|
| 196 | + | ||
| 197 | + | pub async fn add_drop_comment(pool: &Pool, repo_id: i64, author_id: i64, author: &str, body: &str) -> sqlx::Result<i64> { |
|
| 198 | + | sqlx::query_scalar("INSERT INTO drop_comments (repo_id, author_id, author, body, created_ms) VALUES ($1, $2, $3, $4, $5) RETURNING id") |
|
| 199 | + | .bind(repo_id) |
|
| 200 | + | .bind(author_id) |
|
| 201 | + | .bind(author) |
|
| 202 | + | .bind(body) |
|
| 203 | + | .bind(now_ms()) |
|
| 204 | + | .fetch_one(pool) |
|
| 205 | + | .await |
|
| 206 | + | } |
|
| 207 | + | ||
| 208 | + | /// A drop's comments, oldest first. |
|
| 209 | + | pub async fn drop_comments(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<DropComment>> { |
|
| 210 | + | sqlx::query_as("SELECT * FROM drop_comments WHERE repo_id = $1 ORDER BY created_ms, id").bind(repo_id).fetch_all(pool).await |
|
| 211 | + | } |
|
| 212 | + | ||
| 213 | + | pub async fn drop_comment(pool: &Pool, repo_id: i64, id: i64) -> sqlx::Result<Option<DropComment>> { |
|
| 214 | + | sqlx::query_as("SELECT * FROM drop_comments WHERE repo_id = $1 AND id = $2").bind(repo_id).bind(id).fetch_optional(pool).await |
|
| 215 | + | } |
|
| 216 | + | ||
| 217 | + | pub async fn delete_drop_comment(pool: &Pool, repo_id: i64, id: i64) -> sqlx::Result<()> { |
|
| 218 | + | sqlx::query("DELETE FROM drop_comments WHERE repo_id = $1 AND id = $2").bind(repo_id).bind(id).execute(pool).await?; |
|
| 219 | + | Ok(()) |
|
| 220 | + | } |
|
| 221 | + | ||
| 186 | 222 | /// An owner's drops, newest activity first. |
|
| 187 | 223 | pub async fn drops_of(pool: &Pool, owner: &str) -> sqlx::Result<Vec<RepoRow>> { |
|
| 188 | 224 | sqlx::query_as("SELECT * FROM repositories WHERE kind = 'drop' AND owner = $1 ORDER BY updated_ms DESC, id DESC") |
|
| … | 315 unchanged lines |
modified crates/hub/src/notify/mod.rs +1 −1
| … | 108 unchanged lines |
||
| 109 | 109 | "proposal.review_requested" => "👀 Review Requested", |
|
| 110 | 110 | "proposal.review" => "📝 New Review", |
|
| 111 | 111 | "proposal.landed" => "🚀 Proposal Landed", |
|
| 112 | - | "proposal.comment" | "issue.comment" => "💬 New Comment", |
|
| 112 | + | "proposal.comment" | "issue.comment" | "drop.comment" => "💬 New Comment", |
|
| 113 | 113 | "issue.opened" => "🐛 New Issue", |
|
| 114 | 114 | "issue.assigned" => "📌 Issue Assigned", |
|
| 115 | 115 | "issue.closed" => "✔️ Issue Closed", |
|
| … | 46 unchanged lines |
modified crates/hub/src/web/drops.rs +99 −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 comments, rendered, and whether the viewer may delete each (#280). |
|
| 67 | + | comments: Vec<ShownComment>, |
|
| 66 | 68 | /// Its stars, and whether the viewer gave one (#280). |
|
| 67 | 69 | stars: i64, |
|
| 68 | 70 | starred: bool, |
|
| … | 8 unchanged lines |
||
| 77 | 79 | updated_ms: i64, |
|
| 78 | 80 | } |
|
| 79 | 81 | ||
| 82 | + | /// A comment as the drop's page shows it. |
|
| 83 | + | pub struct ShownComment { |
|
| 84 | + | pub id: i64, |
|
| 85 | + | pub author: String, |
|
| 86 | + | pub created_ms: i64, |
|
| 87 | + | pub html: String, |
|
| 88 | + | pub may_delete: bool, |
|
| 89 | + | } |
|
| 90 | + | ||
| 80 | 91 | #[derive(Template)] |
|
| 81 | 92 | #[template(path = "drop_form.html")] |
|
| 82 | 93 | pub struct DropForm { |
|
| … | 107 unchanged lines |
||
| 190 | 201 | Some(id) => db::repos::get_by_id(&app.db, id).await.ok().flatten().filter(|r| r.is_drop()).map(|r| drops::url_of(&r)), |
|
| 191 | 202 | None => None, |
|
| 192 | 203 | }; |
|
| 204 | + | let comments = db::repos::drop_comments(&app.db, row.id) |
|
| 205 | + | .await |
|
| 206 | + | .unwrap_or_default() |
|
| 207 | + | .into_iter() |
|
| 208 | + | .map(|c| ShownComment { |
|
| 209 | + | may_delete: may_delete_comment(viewer, &row, &c), |
|
| 210 | + | id: c.id, |
|
| 211 | + | html: markdown::render(&c.body, "", ""), |
|
| 212 | + | author: c.author, |
|
| 213 | + | created_ms: c.created_ms, |
|
| 214 | + | }) |
|
| 215 | + | .collect(); |
|
| 193 | 216 | let stars = db::repos::counts(&app.db, row.id).await.map(|(s, _)| s).unwrap_or(0); |
|
| 194 | 217 | let starred = match viewer.account() { |
|
| 195 | 218 | Some(a) => db::repos::starred(&app.db, a.id, row.id).await.unwrap_or(false), |
|
| 196 | 219 | None => false, |
|
| 197 | 220 | }; |
|
| 198 | 221 | Ok(Page(DropPage { |
|
| 222 | + | comments, |
|
| 199 | 223 | stars, |
|
| 200 | 224 | starred, |
|
| 201 | 225 | signed_in: viewer.account().is_some(), |
|
| … | 246 unchanged lines |
||
| 448 | 472 | let row = writable(&app, &shell, &viewer, &owner, &id, true).await?; |
|
| 449 | 473 | drops::delete(&app, &me.account, &row).await.map_err(|e| Error::bad(shell.clone(), e.to_string()))?; |
|
| 450 | 474 | Ok(Redirect::to(&format!("/{}/drops?deleted=1", row.owner)).into_response()) |
|
| 475 | + | } |
|
| 476 | + | ||
| 477 | + | /// Who may delete a comment: its author, the drop's owner, a site |
|
| 478 | + | /// administrator. |
|
| 479 | + | fn may_delete_comment(viewer: &Viewer, row: &db::repos::RepoRow, c: &db::repos::DropComment) -> bool { |
|
| 480 | + | viewer.account().is_some_and(|a| !a.blocked() && (Some(a.id) == c.author_id || a.username == row.owner || a.is_admin)) |
|
| 481 | + | } |
|
| 482 | + | ||
| 483 | + | /// The longest comment taken, in bytes. |
|
| 484 | + | const COMMENT_MAX: usize = 64 * 1024; |
|
| 485 | + | ||
| 486 | + | #[derive(Deserialize)] |
|
| 487 | + | pub struct CommentForm { |
|
| 488 | + | #[serde(rename = "_csrf")] |
|
| 489 | + | csrf_token: String, |
|
| 490 | + | body: String, |
|
| 491 | + | } |
|
| 492 | + | ||
| 493 | + | /// `POST /{owner}/drops/{id}/comment` (#280): a reader's comment, in |
|
| 494 | + | /// Markdown; the drop's owner is told. |
|
| 495 | + | pub async fn comment( |
|
| 496 | + | State(app): State<Shared>, |
|
| 497 | + | viewer: Viewer, |
|
| 498 | + | Path((owner, id)): Path<(String, String)>, |
|
| 499 | + | Form(f): Form<CommentForm>, |
|
| 500 | + | ) -> Result<Response, Error> { |
|
| 501 | + | let shell = Shell::new(&app, &format!("/{owner}/drops/{id}"), &viewer); |
|
| 502 | + | let me = require(&viewer, &shell)?; |
|
| 503 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 504 | + | let row = lookup(&app, &shell, &viewer, &owner, &id).await?; |
|
| 505 | + | let body = f.body.trim(); |
|
| 506 | + | if body.is_empty() || body.len() > COMMENT_MAX || me.account.blocked() { |
|
| 507 | + | return Err(Error::bad(shell, "A comment is some text, at most 64 KiB.")); |
|
| 508 | + | } |
|
| 509 | + | let n = db::repos::add_drop_comment(&app.db, row.id, me.account.id, &me.account.username, body) |
|
| 510 | + | .await |
|
| 511 | + | .map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 512 | + | let url = drops::url_of(&row); |
|
| 513 | + | if me.account.username != row.owner { |
|
| 514 | + | if let Ok(Some(owner_account)) = db::accounts::account_by_name(&app.db, &row.owner).await { |
|
| 515 | + | let title = format!("{} on your drop {}", me.account.username, drops::shown_title(&row, &[])); |
|
| 516 | + | crate::notify::tell( |
|
| 517 | + | &app, |
|
| 518 | + | "drop.comment", |
|
| 519 | + | &title, |
|
| 520 | + | &format!("{url}#comment-{n}"), |
|
| 521 | + | &me.account.username, |
|
| 522 | + | Some(row.id), |
|
| 523 | + | vec![(owner_account.id, db::notify::Reason::Participating)], |
|
| 524 | + | ) |
|
| 525 | + | .await; |
|
| 526 | + | } |
|
| 527 | + | } |
|
| 528 | + | Ok(Redirect::to(&format!("{url}#comment-{n}")).into_response()) |
|
| 529 | + | } |
|
| 530 | + | ||
| 531 | + | /// `POST /{owner}/drops/{id}/comment/{n}/delete` (#280). |
|
| 532 | + | pub async fn delete_comment( |
|
| 533 | + | State(app): State<Shared>, |
|
| 534 | + | viewer: Viewer, |
|
| 535 | + | Path((owner, id, n)): Path<(String, String, i64)>, |
|
| 536 | + | Form(f): Form<DeleteForm>, |
|
| 537 | + | ) -> Result<Response, Error> { |
|
| 538 | + | let shell = Shell::new(&app, &format!("/{owner}/drops/{id}"), &viewer); |
|
| 539 | + | require(&viewer, &shell)?; |
|
| 540 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 541 | + | let row = lookup(&app, &shell, &viewer, &owner, &id).await?; |
|
| 542 | + | let c = db::repos::drop_comment(&app.db, row.id, n).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 543 | + | match c { |
|
| 544 | + | Some(c) if may_delete_comment(&viewer, &row, &c) => { |
|
| 545 | + | db::repos::delete_drop_comment(&app.db, row.id, n).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 546 | + | Ok(Redirect::to(&format!("{}#comments", drops::url_of(&row))).into_response()) |
|
| 547 | + | } |
|
| 548 | + | _ => Err(Error::not_found(shell)), |
|
| 549 | + | } |
|
| 451 | 550 | } |
|
| 452 | 551 | ||
| 453 | 552 | /// `POST /{owner}/drops/{id}/star` (#280): star it, or take the star back. |
|
| … | 78 unchanged lines |
modified crates/hub/src/web/mod.rs +2 −0
| … | 196 unchanged lines |
||
| 197 | 197 | .route("/{owner}/drops/{id}/delete", post(drops::delete)) |
|
| 198 | 198 | .route("/{owner}/drops/{id}/fork", post(drops::fork)) |
|
| 199 | 199 | .route("/{owner}/drops/{id}/star", post(drops::star)) |
|
| 200 | + | .route("/{owner}/drops/{id}/comment", post(drops::comment)) |
|
| 201 | + | .route("/{owner}/drops/{id}/comment/{n}/delete", post(drops::delete_comment)) |
|
| 200 | 202 | .route("/{owner}/drops/{id}/changes/{r}", get(drops::changes)) |
|
| 201 | 203 | .route("/{owner}/drops/{id}/change/{hash}", get(drops::change)) |
|
| 202 | 204 | .route("/{owner}/drops/{id}/raw/{r}/{*path}", get(drops::raw)) |
|
| … | 702 unchanged lines |
modified crates/hub/tests/drops.rs +32 −0
| … | 495 unchanged lines |
||
| 496 | 496 | star(andy.clone()).await; |
|
| 497 | 497 | assert!(get(app, &source, Some(&andy)).await.body.contains("☆ STAR 1")); |
|
| 498 | 498 | assert!(get(app, &source, None).await.body.contains("1 star"), "the signed out see the count"); |
|
| 499 | + | ||
| 500 | + | // Comments: in Markdown; its owner is told; the author or the owner |
|
| 501 | + | // deletes one, nobody else. |
|
| 502 | + | let comment = |cookie: String, body: &'static str| { |
|
| 503 | + | let (app, source) = (app.clone(), source.clone()); |
|
| 504 | + | async move { |
|
| 505 | + | let csrf = csrf_of(&get(&app, &source, Some(&cookie)).await.body); |
|
| 506 | + | let r = post(&app, &format!("{source}/comment"), Some(&cookie), &format!("_csrf={csrf}&body={}", enc(body))).await; |
|
| 507 | + | assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
|
| 508 | + | r.location.unwrap().rsplit_once("#comment-").unwrap().1.to_string() |
|
| 509 | + | } |
|
| 510 | + | }; |
|
| 511 | + | let from_andy = comment(andy.clone(), "Nice **notes**").await; |
|
| 512 | + | let from_carol = comment(carol.clone(), "Thanks").await; |
|
| 513 | + | let page = get(app, &source, None).await.body; |
|
| 514 | + | assert!(page.contains("<strong>notes</strong>") && page.contains(&format!("id=\"comment-{from_andy}\"")), "{page}"); |
|
| 515 | + | assert!(page.contains("Sign in to comment.")); |
|
| 516 | + | let carol_row = db::accounts::account_by_name(&app.db, "carol").await.unwrap().unwrap(); |
|
| 517 | + | let told = db::notify::inbox(&app.db, carol_row.id, 10).await.unwrap(); |
|
| 518 | + | assert!(told.iter().any(|n| n.kind == "drop.comment" && n.url.ends_with(&format!("#comment-{from_andy}"))), "{told:?}"); |
|
| 519 | + | let delete = |cookie: String, n: String| { |
|
| 520 | + | let (app, source) = (app.clone(), source.clone()); |
|
| 521 | + | async move { |
|
| 522 | + | let csrf = csrf_of(&get(&app, &source, Some(&cookie)).await.body); |
|
| 523 | + | post(&app, &format!("{source}/comment/{n}/delete"), Some(&cookie), &format!("_csrf={csrf}")).await.status |
|
| 524 | + | } |
|
| 525 | + | }; |
|
| 526 | + | let dave = register(app, "dave").await; |
|
| 527 | + | assert_eq!(delete(dave, from_andy.clone()).await, StatusCode::NOT_FOUND, "not someone else's"); |
|
| 528 | + | assert_eq!(delete(carol.clone(), from_andy.clone()).await, StatusCode::SEE_OTHER, "the drop's owner may"); |
|
| 529 | + | assert_eq!(delete(carol.clone(), from_carol.clone()).await, StatusCode::SEE_OTHER, "and the author"); |
|
| 530 | + | assert!(get(app, &source, None).await.body.contains("No comments yet.")); |
|
| 499 | 531 | } |
|
| 500 | 532 | ||
| 501 | 533 | #[tokio::test(flavor = "multi_thread")] |
|
| … | 27 unchanged lines |
modified docs/spec.md +1 −0
| … | 179 unchanged lines |
||
| 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 | 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. |
|
| 183 | + | - **Comments** (#280): signed-in readers comment on a drop in Markdown (`POST /{owner}/drops/{id}/comment`, table `drop_comments`, migration 0092, at most 64 KiB), shown oldest first under its files; the owner is told (`drop.comment`); the author, the owner or a site administrator deletes one (`…/comment/{n}/delete`). |
|
| 183 | 184 | - **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). |
|
| 184 | 185 | - **Later:** forks, stars, comments, embedding a drop in another page, and drops in backups. |
|
| 185 | 186 | ||
| … | 705 unchanged lines |
modified templates/drop.html +19 −0
| … | 33 unchanged lines |
||
| 34 | 34 | {% endif %} |
|
| 35 | 35 | </section> |
|
| 36 | 36 | {% endfor %} |
|
| 37 | + | <section class="card-box drop-comments" id="comments"> |
|
| 38 | + | <div class="card-head"><h2>Comments</h2><span class="count-chip">{{ comments.len() }}</span></div> |
|
| 39 | + | {% if comments.is_empty() %}<p class="empty small">No comments yet.</p>{% else %} |
|
| 40 | + | <ul class="rows drop-comment-rows">{% for c in comments %} |
|
| 41 | + | <li id="comment-{{ c.id }}"> |
|
| 42 | + | <div class="drop-comment"> |
|
| 43 | + | <div class="small mute"><a href="/{{ c.author }}">{{ c.author }}</a> · <time title="{{ c.created_ms|when_i }}">{{ c.created_ms|ago_i }}</time></div> |
|
| 44 | + | <div class="doc">{{ c.html|safe }}</div> |
|
| 45 | + | </div> |
|
| 46 | + | {% if c.may_delete %}<form class="inline" method="post" action="{{ repo.url }}/comment/{{ c.id }}/delete" data-confirm="Delete this comment?"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button class="small danger">Delete</button></form>{% endif %} |
|
| 47 | + | </li>{% endfor %} |
|
| 48 | + | </ul>{% endif %} |
|
| 49 | + | {% if signed_in %}<form class="pad drop-comment-form" method="post" action="{{ repo.url }}/comment"> |
|
| 50 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 51 | + | <label for="comment-body" class="small mute">Comment (Markdown)</label> |
|
| 52 | + | <textarea id="comment-body" name="body" rows="4" required maxlength="65536"></textarea> |
|
| 53 | + | <div class="actions"><button type="submit">COMMENT</button></div> |
|
| 54 | + | </form>{% else %}<p class="small mute pad">Sign in to comment.</p>{% endif %} |
|
| 55 | + | </section> |
|
| 37 | 56 | {% endblock %} |
modified templates/style.css +4 −0
| … | 1425 unchanged lines |
||
| 1426 | 1426 | .drop-clone label { color: var(--mute); } |
|
| 1427 | 1427 | .drop-clone input { flex: 0 1 640px; min-width: 0; } |
|
| 1428 | 1428 | .drop-filebox article.doc { max-width: none; margin: 0; border: 0; } |
|
| 1429 | + | .drop-comment-rows li { align-items: flex-start; } |
|
| 1430 | + | .drop-comment { flex: 1; min-width: 0; } |
|
| 1431 | + | .drop-comment .doc { margin-top: 4px; overflow-wrap: anywhere; } |
|
| 1432 | + | .drop-comment-form textarea { width: 100%; box-sizing: border-box; } |
|
| 1429 | 1433 | .drop-file { border: 1px solid var(--line); border-radius: 6px; padding: 8px 12px; margin: 0 0 12px; } |
|
| 1430 | 1434 | .drop-file-actions { display: flex; align-items: flex-end; justify-content: flex-end; } |