anvilsign in

collin/anvil · 06f1be4a

feat: mobile-friendly board + replace Issues with the TODO.md board

Collin Richards · 2026-06-10 16:53 UTC · 06f1be4ac0e127e30bfbe622bb76903bc4c6772a · parent 10116d77 · browse files

modifiedcrates/anvil-web/assets/icons.svg+1 −1
1-<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" style="display:none" aria-hidden="true"><symbol id="i-clipboard" viewBox="0 0 16 16"><path d="M10 1.5H6a.5.5 0 0 0-.5.5v1H4A1.5 1.5 0 0 0 2.5 4.5v9A1.5 1.5 0 0 0 4 15h8a1.5 1.5 0 0 0 1.5-1.5v-9A1.5 1.5 0 0 0 12 3h-1.5V2a.5.5 0 0 0-.5-.5zm-3.5 1h3v1h-3v-1zM4 4.5h8v9H4v-9z"/></symbol><symbol id="i-pencil" viewBox="0 0 16 16"><path d="M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61Zm.176 4.225-1.69-1.69-6.857 6.857-.563 1.965 1.965-.563 6.857-6.857-.001-.001Z"/></symbol><symbol id="i-plus" viewBox="0 0 16 16"><path d="M7.25 7.25V2h1.5v5.25H14v1.5H8.75V14h-1.5V8.75H2v-1.5h5.25Z"/></symbol><symbol id="i-folder" viewBox="0 0 16 16"><path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75Z"/></symbol><symbol id="i-file" viewBox="0 0 16 16"><path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z"/></symbol></svg>
1+<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" style="display:none" aria-hidden="true"><symbol id="i-clipboard" viewBox="0 0 16 16"><path d="M10 1.5H6a.5.5 0 0 0-.5.5v1H4A1.5 1.5 0 0 0 2.5 4.5v9A1.5 1.5 0 0 0 4 15h8a1.5 1.5 0 0 0 1.5-1.5v-9A1.5 1.5 0 0 0 12 3h-1.5V2a.5.5 0 0 0-.5-.5zm-3.5 1h3v1h-3v-1zM4 4.5h8v9H4v-9z"/></symbol><symbol id="i-pencil" viewBox="0 0 16 16"><path d="M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61Zm.176 4.225-1.69-1.69-6.857 6.857-.563 1.965 1.965-.563 6.857-6.857-.001-.001Z"/></symbol><symbol id="i-plus" viewBox="0 0 16 16"><path d="M7.25 7.25V2h1.5v5.25H14v1.5H8.75V14h-1.5V8.75H2v-1.5h5.25Z"/></symbol><symbol id="i-folder" viewBox="0 0 16 16"><path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75Z"/></symbol><symbol id="i-file" viewBox="0 0 16 16"><path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z"/></symbol><symbol id="i-grip" viewBox="0 0 16 16"><circle cx="6" cy="4" r="1.3"/><circle cx="10" cy="4" r="1.3"/><circle cx="6" cy="8" r="1.3"/><circle cx="10" cy="8" r="1.3"/><circle cx="6" cy="12" r="1.3"/><circle cx="10" cy="12" r="1.3"/></symbol></svg>
deletedcrates/anvil-web/src/issues.rs+0 −422
1-//! Per-repository issue tracker UI: list, new-issue form, detail page with
2-//! comments, and open/close.
3-//!
4-//! Access mirrors the rest of the forge: anyone who can read the repo can
5-//! read its issues; any logged-in reader can open issues and comment;
6-//! closing/reopening is for the issue author or anyone with write access.
7-//! Bodies are markdown, rendered with the same pipeline as file views.
8-
9-use std::collections::HashMap;
10-
11-use anvil_core::{
12- App,
13- Issue,
14- User,
15- access,
16- issues,
17- users,
18-};
19-use axum::{
20- Form,
21- Router,
22- extract::{
23- Path,
24- Query,
25- State,
26- },
27- response::{
28- IntoResponse,
29- Redirect,
30- Response,
31- },
32- routing::get,
33-};
34-use maud::{
35- Markup,
36- html,
37-};
38-use serde::Deserialize;
39-
40-use crate::{
41- auth::{
42- Csrf,
43- CurrentUser,
44- verify_csrf,
45- },
46- ui::{
47- csrf_input,
48- fmt_relative,
49- fmt_time,
50- layout,
51- not_found,
52- render_markdown,
53- resolve_repo,
54- server_error,
55- },
56-};
57-
58-/// Mount the issue routes.
59-pub fn routes(router: Router<App>) -> Router<App> {
60- router
61- .route("/{owner}/{repo}/issues", get(list))
62- .route("/{owner}/{repo}/issues/new", get(new_form).post(new_submit))
63- .route("/{owner}/{repo}/issues/{number}", get(detail))
64- .route(
65- "/{owner}/{repo}/issues/{number}/comment",
66- axum::routing::post(comment_submit),
67- )
68- .route(
69- "/{owner}/{repo}/issues/{number}/state",
70- axum::routing::post(state_submit),
71- )
72-}
73-
74-#[derive(Deserialize)]
75-struct ListQuery {
76- #[serde(default)]
77- state: String,
78-}
79-
80-/// `GET /{owner}/{repo}/issues` — open issues, with a closed tab.
81-async fn list(
82- State(app): State<App>,
83- CurrentUser(user): CurrentUser,
84- Path((owner, repo)): Path<(String, String)>,
85- Query(q): Query<ListQuery>,
86-) -> Result<Markup, Response> {
87- let (_, meta) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
88- let state = if q.state == issues::state::CLOSED {
89- issues::state::CLOSED
90- } else {
91- issues::state::OPEN
92- };
93- let items = issues::list(&app.db, meta.id, state)
94- .await
95- .map_err(server_error)?;
96- let (open, closed) = issues::counts(&app.db, meta.id)
97- .await
98- .map_err(server_error)?;
99- let names = usernames(&app, items.iter().map(|i| i.author_id)).await;
100-
101- Ok(layout(
102- &format!("{owner}/{repo}: issues"),
103- user.as_ref(),
104- html! {
105- div style="display:flex;align-items:center;gap:8px" {
106- h1 style="margin-right:auto" {
107- a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } " · issues"
108- }
109- @if user.is_some() {
110- a.btn href=(format!("/{owner}/{repo}/issues/new")) { "New issue" }
111- }
112- }
113- p {
114- span.pill-group {
115- a.pill.active[state == issues::state::OPEN]
116- href=(format!("/{owner}/{repo}/issues")) { (open) " open" }
117- a.pill.active[state == issues::state::CLOSED]
118- href=(format!("/{owner}/{repo}/issues?state=closed")) { (closed) " closed" }
119- }
120- }
121- @if items.is_empty() {
122- p.muted {
123- @if state == issues::state::OPEN { "No open issues." }
124- @else { "No closed issues." }
125- }
126- } @else {
127- div.box {
128- @for issue in &items {
129- div.row {
130- a.entry href=(format!("/{owner}/{repo}/issues/{}", issue.number)) {
131- (state_dot(&issue.state))
132- (issue.title)
133- }
134- span.muted style="white-space:nowrap" {
135- "#" (issue.number)
136- " · " (names.get(&issue.author_id).map(String::as_str).unwrap_or("?"))
137- " · " span title=(fmt_time(issue.updated_at)) { (fmt_relative(issue.updated_at)) }
138- }
139- }
140- }
141- }
142- }
143- },
144- ))
145-}
146-
147-/// `GET /{owner}/{repo}/issues/new` — the new-issue form (login required).
148-async fn new_form(
149- State(app): State<App>,
150- CurrentUser(user): CurrentUser,
151- csrf: Csrf,
152- Path((owner, repo)): Path<(String, String)>,
153-) -> Result<Markup, Response> {
154- resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
155- let Some(user) = user else {
156- return Err(Redirect::to("/-/login").into_response());
157- };
158- Ok(new_issue_page(&owner, &repo, Some(&user), None, &csrf.0))
159-}
160-
161-#[derive(Deserialize)]
162-struct NewIssueForm {
163- #[serde(default)]
164- title: String,
165- #[serde(default)]
166- body: String,
167- #[serde(default)]
168- csrf: String,
169-}
170-
171-/// `POST /{owner}/{repo}/issues/new`
172-async fn new_submit(
173- State(app): State<App>,
174- CurrentUser(user): CurrentUser,
175- csrf: Csrf,
176- Path((owner, repo)): Path<(String, String)>,
177- Form(form): Form<NewIssueForm>,
178-) -> Response {
179- let (_, meta) = match resolve_repo(&app, user.as_ref(), &owner, &repo).await {
180- Ok(v) => v,
181- Err(resp) => return resp,
182- };
183- let Some(user) = user else {
184- return Redirect::to("/-/login").into_response();
185- };
186- if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
187- return resp;
188- }
189- match issues::create(&app.db, meta.id, user.id, &form.title, &form.body).await {
190- Ok(issue) => {
191- Redirect::to(&format!("/{owner}/{repo}/issues/{}", issue.number)).into_response()
192- }
193- Err(anvil_core::Error::Invalid(m)) => {
194- new_issue_page(&owner, &repo, Some(&user), Some(&m), &csrf.0).into_response()
195- }
196- Err(e) => server_error(e),
197- }
198-}
199-
200-fn new_issue_page(
201- owner: &str,
202- repo: &str,
203- user: Option<&User>,
204- error: Option<&str>,
205- csrf: &str,
206-) -> Markup {
207- layout(
208- &format!("{owner}/{repo}: new issue"),
209- user,
210- html! {
211- h1 {
212- a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) }
213- " · " a href=(format!("/{owner}/{repo}/issues")) { "issues" }
214- " · new"
215- }
216- @if let Some(error) = error { p style="color:#cf222e" { (error) } }
217- form.stack method="post" action=(format!("/{owner}/{repo}/issues/new")) {
218- (csrf_input(csrf))
219- p { label { "Title" br; input type="text" name="title" required; } }
220- p { label { "Description (markdown)" br; textarea name="body" rows="8" {} } }
221- p { button.btn type="submit" { "Open issue" } }
222- }
223- },
224- )
225-}
226-
227-/// `GET /{owner}/{repo}/issues/{number}` — one issue with its comments.
228-async fn detail(
229- State(app): State<App>,
230- CurrentUser(user): CurrentUser,
231- csrf: Csrf,
232- Path((owner, repo, number)): Path<(String, String, i64)>,
233-) -> Result<Markup, Response> {
234- let (_, meta) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
235- let issue = issues::find(&app.db, meta.id, number)
236- .await
237- .map_err(server_error)?
238- .ok_or_else(|| not_found("no such issue"))?;
239- let comments = issues::comments(&app.db, issue.id)
240- .await
241- .map_err(server_error)?;
242- let names = usernames(
243- &app,
244- std::iter::once(issue.author_id).chain(comments.iter().map(|c| c.author_id)),
245- )
246- .await;
247- let name = |id: &i64| names.get(id).map(String::as_str).unwrap_or("?").to_string();
248- let may_toggle = user
249- .as_ref()
250- .is_some_and(|u| u.id == issue.author_id || access::can_write(&meta, Some(u)));
251- let open = issue.state == issues::state::OPEN;
252-
253- Ok(layout(
254- &format!("{owner}/{repo}: {} (#{})", issue.title, issue.number),
255- user.as_ref(),
256- html! {
257- h1 {
258- a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) }
259- " · " a href=(format!("/{owner}/{repo}/issues")) { "issues" }
260- }
261- h2 style="font-size:18px;margin:12px 0 4px" { (issue.title) " " span.muted { "#" (issue.number) } }
262- p {
263- (state_badge(&issue.state))
264- " " span.muted {
265- (name(&issue.author_id)) " opened "
266- span title=(fmt_time(issue.created_at)) { (fmt_relative(issue.created_at)) }
267- " · " (comments.len()) " comment" @if comments.len() != 1 { "s" }
268- }
269- }
270- div.box.issue-post {
271- div.issue-head {
272- (name(&issue.author_id))
273- " · " span title=(fmt_time(issue.created_at)) { (fmt_relative(issue.created_at)) }
274- }
275- div.md-body {
276- @if issue.body.is_empty() { p.muted { "No description." } }
277- @else { (render_markdown(&issue.body)) }
278- }
279- }
280- @for c in &comments {
281- div.box.issue-post {
282- div.issue-head {
283- (name(&c.author_id))
284- " · " span title=(fmt_time(c.created_at)) { (fmt_relative(c.created_at)) }
285- }
286- div.md-body { (render_markdown(&c.body)) }
287- }
288- }
289- @if user.is_some() {
290- form.stack method="post" action=(format!("/{owner}/{repo}/issues/{}/comment", issue.number)) {
291- (csrf_input(&csrf.0))
292- p { label { "Comment (markdown)" br; textarea name="body" rows="4" required {} } }
293- p {
294- button.btn type="submit" { "Comment" }
295- @if may_toggle {
296- " "
297- button.btn.btn-secondary type="submit"
298- formaction=(format!("/{owner}/{repo}/issues/{}/state", issue.number))
299- formnovalidate
300- name="state" value=(if open { "closed" } else { "open" }) {
301- @if open { "Close issue" } @else { "Reopen issue" }
302- }
303- }
304- }
305- }
306- } @else {
307- p.muted { a href="/-/login" { "Log in" } " to comment." }
308- }
309- },
310- ))
311-}
312-
313-#[derive(Deserialize)]
314-struct CommentForm {
315- #[serde(default)]
316- body: String,
317- #[serde(default)]
318- csrf: String,
319-}
320-
321-/// `POST /{owner}/{repo}/issues/{number}/comment`
322-async fn comment_submit(
323- State(app): State<App>,
324- CurrentUser(user): CurrentUser,
325- csrf: Csrf,
326- Path((owner, repo, number)): Path<(String, String, i64)>,
327- Form(form): Form<CommentForm>,
328-) -> Response {
329- let (meta, mut issue, user) = match load_for_update(&app, user, &owner, &repo, number).await {
330- Ok(v) => v,
331- Err(resp) => return resp,
332- };
333- let _ = meta;
334- if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
335- return resp;
336- }
337- match issues::comment(&app.db, &mut issue, user.id, &form.body).await {
338- Ok(_) | Err(anvil_core::Error::Invalid(_)) => {
339- Redirect::to(&format!("/{owner}/{repo}/issues/{number}")).into_response()
340- }
341- Err(e) => server_error(e),
342- }
343-}
344-
345-#[derive(Deserialize)]
346-struct StateForm {
347- #[serde(default)]
348- state: String,
349- #[serde(default)]
350- csrf: String,
351-}
352-
353-/// `POST /{owner}/{repo}/issues/{number}/state` — close or reopen.
354-async fn state_submit(
355- State(app): State<App>,
356- CurrentUser(user): CurrentUser,
357- csrf: Csrf,
358- Path((owner, repo, number)): Path<(String, String, i64)>,
359- Form(form): Form<StateForm>,
360-) -> Response {
361- let (meta, mut issue, user) = match load_for_update(&app, user, &owner, &repo, number).await {
362- Ok(v) => v,
363- Err(resp) => return resp,
364- };
365- if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
366- return resp;
367- }
368- let may_toggle = user.id == issue.author_id || access::can_write(&meta, Some(&user));
369- if !may_toggle {
370- return not_found("no such issue");
371- }
372- let new_state = if form.state == issues::state::CLOSED {
373- issues::state::CLOSED
374- } else {
375- issues::state::OPEN
376- };
377- match issues::set_state(&app.db, &mut issue, new_state).await {
378- Ok(()) => Redirect::to(&format!("/{owner}/{repo}/issues/{number}")).into_response(),
379- Err(e) => server_error(e),
380- }
381-}
382-
383-/// Shared resolve for the mutating endpoints: readable repo, existing issue,
384-/// logged-in user.
385-async fn load_for_update(
386- app: &App,
387- user: Option<User>,
388- owner: &str,
389- repo: &str,
390- number: i64,
391-) -> Result<(anvil_core::Repository, Issue, User), Response> {
392- let (_, meta) = resolve_repo(app, user.as_ref(), owner, repo).await?;
393- let issue = issues::find(&app.db, meta.id, number)
394- .await
395- .map_err(server_error)?
396- .ok_or_else(|| not_found("no such issue"))?;
397- let user = user.ok_or_else(|| Redirect::to("/-/login").into_response())?;
398- Ok((meta, issue, user))
399-}
400-
401-/// author_id → username for the ids in `ids`.
402-async fn usernames(app: &App, ids: impl Iterator<Item = i64>) -> HashMap<i64, String> {
403- let mut out = HashMap::new();
404- for id in ids {
405- if let std::collections::hash_map::Entry::Vacant(entry) = out.entry(id)
406- && let Ok(Some(u)) = users::find_by_id(&app.db, id).await
407- {
408- entry.insert(u.username);
409- }
410- }
411- out
412-}
413-
414-/// Small open/closed indicator for list rows.
415-fn state_dot(state: &str) -> Markup {
416- html! { span class=(format!("issue-dot {state}")) {} }
417-}
418-
419-/// Open/closed pill for the detail page.
420-fn state_badge(state: &str) -> Markup {
421- html! { span class=(format!("st issue-{state}")) { (state) } }
422-}
modifiedcrates/anvil-web/src/lib.rs+0 −2
⋯ 24 unchanged lines
2525 pub mod attachments;
2626 pub mod auth;
2727 pub mod git_http;
28-pub mod issues;
2928 pub mod pages;
3029 pub mod todomd;
3130 pub mod ui;
⋯ 12 unchanged lines
4443 router,
4544 app.config.http.attachment_max_mb.saturating_mul(1 << 20),
4645 ); // uploaded image attachments
47- router = issues::routes(router); // per-repo issue tracker
4846 router = git_http::routes(router); // smart-HTTP git endpoints
4947 router
5048 // Derives the per-request CSRF token so the layout can attach it to
⋯ 22 unchanged lines
modifiedcrates/anvil-web/src/todomd.rs+43 −30
⋯ 628 unchanged lines
629629 }
630630 @for t in &s.tasks {
631631 div.card.done[t.done]
632- draggable=[actions.map(|_| "true")]
633632 data-title=[actions.map(|_| t.title.as_str())] {
634633 @if let Some(a) = actions {
634+ span.card-grip title="Drag to reorder" aria-hidden="true" {
635+ (crate::ui::icon(crate::ui::Icon::Grip))
636+ }
635637 form.card-del method="post"
636638 action=(format!("/{}/{}/delete-task/{}/{}", a.owner, a.repo, crate::ui::enc_ref(a.rev), a.path)) {
637639 (crate::ui::csrf_input(a.csrf))
⋯ 25 unchanged lines
663665 })
664666 }
665667
666-/// Drag-and-drop reordering for the board: drag a card within or across
667-/// columns; on drop, POST the new (section, index) to the move endpoint and
668-/// reload. Reads the endpoint/tip/csrf from the `.kanban` element's data-attrs.
668+/// Drag-and-drop reordering for the board, via a per-card grip handle and
669+/// Pointer Events — so it works with mouse, touch, and pen alike (plain HTML5
670+/// drag-and-drop never fires on touchscreens). Grab a card's grip, drag within
671+/// or across columns; the card reorders live, and on release the new
672+/// (section, index) is POSTed to the move endpoint and the page reloads.
673+/// Reads the endpoint/tip/csrf from the `.kanban` element's data-attrs.
669674 const BOARD_DND_JS: &str = r#"
670675 (function(){
671676 var board = document.querySelector('.kanban[data-move-url]');
672677 if (!board) return;
673678 var url = board.dataset.moveUrl, tip = board.dataset.tip, csrf = board.dataset.csrf;
674- var dragged = null, fromSection = null;
679+ var card = null, fromSection = null, grip = null, pid = null;
675680
676- board.addEventListener('dragstart', function(e){
677- var card = e.target.closest('.card');
678- if (!card) return;
679- dragged = card;
680- fromSection = card.closest('.col').dataset.section;
681- setTimeout(function(){ card.classList.add('dragging'); }, 0);
682- e.dataTransfer.effectAllowed = 'move';
683- });
684- board.addEventListener('dragend', function(){
685- if (dragged) dragged.classList.remove('dragging');
686- dragged = null;
687- });
688681 function afterCard(col, y){
689682 var cards = [].slice.call(col.querySelectorAll('.card:not(.dragging)'));
690683 var best = null, bestOffset = -Infinity;
⋯ 4 unchanged lines
695688 });
696689 return best;
697690 }
698- board.addEventListener('dragover', function(e){
699- if (!dragged) return;
700- var col = e.target.closest('.col');
701- if (!col) return;
691+ function onMove(e){
692+ if (!card) return;
702693 e.preventDefault();
694+ var under = document.elementFromPoint(e.clientX, e.clientY);
695+ var col = (under && under.closest && under.closest('.col')) || card.closest('.col');
703696 var after = afterCard(col, e.clientY);
704- if (after == null) col.appendChild(dragged);
705- else col.insertBefore(dragged, after);
706- });
707- board.addEventListener('drop', function(e){
708- if (!dragged) return;
709- e.preventDefault();
710- var col = dragged.closest('.col');
697+ if (after == null) col.appendChild(card);
698+ else col.insertBefore(card, after);
699+ }
700+ function onUp(){
701+ if (!card) return;
702+ grip.removeEventListener('pointermove', onMove);
703+ grip.removeEventListener('pointerup', onUp);
704+ grip.removeEventListener('pointercancel', onUp);
705+ var c = card;
706+ card = null;
707+ c.classList.remove('dragging');
708+ var col = c.closest('.col');
711709 var cards = [].slice.call(col.querySelectorAll('.card'));
712710 var body = new URLSearchParams();
713711 body.set('csrf', csrf);
714712 body.set('expected_tip', tip);
715713 body.set('from_section', fromSection);
716714 body.set('to_section', col.dataset.section);
717- body.set('title', dragged.dataset.title);
718- body.set('to_index', String(cards.indexOf(dragged)));
715+ body.set('title', c.dataset.title);
716+ body.set('to_index', String(cards.indexOf(c)));
719717 fetch(url, {
720718 method: 'POST',
721719 headers: {'X-CSRF-Token': csrf, 'Content-Type': 'application/x-www-form-urlencoded'},
⋯ 2 unchanged lines
724722 if (!r.ok) throw new Error('move failed (' + r.status + ')');
725723 location.reload();
726724 }).catch(function(err){ console.error(err); location.reload(); });
725+ }
726+ board.addEventListener('pointerdown', function(e){
727+ var g = e.target.closest('.card-grip');
728+ if (!g) return;
729+ card = g.closest('.card');
730+ if (!card) return;
731+ e.preventDefault();
732+ grip = g;
733+ pid = e.pointerId;
734+ fromSection = card.closest('.col').dataset.section;
735+ card.classList.add('dragging');
736+ grip.setPointerCapture(pid);
737+ grip.addEventListener('pointermove', onMove);
738+ grip.addEventListener('pointerup', onUp);
739+ grip.addEventListener('pointercancel', onUp);
727740 });
728741 })();
729742 "#;
⋯ 261 unchanged lines
modifiedcrates/anvil-web/src/ui.rs+13 −4
⋯ 199 unchanged lines
200200 .kanban .card:hover .card-del-btn, .card-del-btn:focus { opacity:1; }
201201 .kanban .card-del-btn:hover { color:#cf222e; background:var(--code-bg); }
202202 .kanban .card .title { padding-right:14px; }
203-.kanban .card[draggable="true"] { cursor:grab; }
204-.kanban .card.dragging { opacity:.4; cursor:grabbing; }
203+.kanban .card:has(.card-grip) { padding-left:26px; }
204+.kanban .card-grip { position:absolute; left:3px; top:7px; color:var(--muted); cursor:grab; touch-action:none; line-height:0; padding:2px; border-radius:4px; }
205+.kanban .card-grip:hover { color:var(--fg); background:var(--code-bg); }
206+.kanban .card.dragging { opacity:.4; pointer-events:none; }
207+.kanban .card.dragging .card-grip { pointer-events:auto; cursor:grabbing; }
205208 .kanban .card .title p { margin:0; font-weight:500; }
206209 .kanban .card.done .title { color:var(--muted); text-decoration:line-through; font-weight:400; }
207210 .kanban .card details { margin-top:7px; }
⋯ 52 unchanged lines
260263 .nav-dropdown a.current { font-weight:600; }
261264 details.rev-menu { display:inline-block; }
262265 details.rev-menu > summary .pill { cursor:pointer; }
266+@media (max-width:720px) {
267+ .kanban { flex-direction:column; gap:14px; overflow-x:visible; }
268+ .kanban .col { min-width:0; width:100%; }
269+}
263270 "#;
264271
265272 /// Icon set as an SVG sprite (a hidden `<svg>` of `<symbol id="i-…">`s),
⋯ 11 unchanged lines
277284 Plus,
278285 Folder,
279286 File,
287+ Grip,
280288 }
281289
282290 impl Icon {
⋯ 5 unchanged lines
288296 Icon::Plus => "i-plus",
289297 Icon::Folder => "i-folder",
290298 Icon::File => "i-file",
299+ Icon::Grip => "i-grip",
291300 }
292301 }
293302 }
294303
295304 /// Reference a sprite symbol as an inline `<svg>`, sized/colored by the `.icon`
296305 /// CSS (1em, `currentColor`).
297-fn icon(i: Icon) -> Markup {
306+pub(crate) fn icon(i: Icon) -> Markup {
298307 icon_with(i, "icon")
299308 }
300309
⋯ 797 unchanged lines
10981107 @if meta.is_private { span.pill { "private" } }
10991108 }
11001109 nav.repo-nav {
1101- a href=(format!("/{owner}/{repo}/issues")) { "Issues" }
1110+ a href=(format!("/{owner}/{repo}/blob/{}/TODO.md", enc_ref(overview.default_branch.as_deref().unwrap_or("main")))) { "Todo" }
11021111 span.sep { "·" }
11031112 a href=(format!("/{owner}/{repo}/ci")) { "CI" }
11041113 span.sep { "·" }
⋯ 1573 unchanged lines