Allow dragging tickets between OPEN, WIP, and DONE on the project board.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-27 23:29:01 +01:00
co-authored by Cursor
parent 34ab3b9c5b
commit f97856b8a6
2 changed files with 141 additions and 12 deletions
+92 -7
View File
@@ -53,6 +53,15 @@
let editTimeEnd = $state('')
let tokenName = $state('tui')
let tokenScope = $state('write')
let dragTicketId = $state('')
let dragOverStatus = $state('')
let suppressTicketClick = false
const boardStatuses = [
['todo', 'OPEN'],
['in_progress', 'WIP'],
['done', 'DONE'],
]
function desiredHash() {
return buildHash({
@@ -591,6 +600,63 @@
return tickets.filter((t) => t.status === status)
}
async function setTicketStatus(ticket, status) {
if (!ticket || ticket.status === status) return
error = ''
const prev = ticket.status
tickets = tickets.map((t) => (t.id === ticket.id ? { ...t, status } : t))
try {
await api.patchTicket(projectId, ticket.id, { status })
if (selected?.id === ticket.id) {
selected = { ...selected, status }
editStatus = status
}
} catch (e) {
tickets = tickets.map((t) => (t.id === ticket.id ? { ...t, status: prev } : t))
error = e.message
}
}
function onTicketDragStart(e, ticket) {
dragTicketId = ticket.id
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', ticket.id)
}
function onTicketDragEnd() {
dragTicketId = ''
dragOverStatus = ''
// Click fires after dragend on some browsers; suppress open-once.
suppressTicketClick = true
setTimeout(() => {
suppressTicketClick = false
}, 0)
}
function onColumnDragOver(e, status) {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
dragOverStatus = status
}
function onColumnDragLeave(e, status) {
if (dragOverStatus === status) dragOverStatus = ''
}
async function onColumnDrop(e, status) {
e.preventDefault()
const id = e.dataTransfer.getData('text/plain') || dragTicketId
dragTicketId = ''
dragOverStatus = ''
const ticket = tickets.find((t) => t.id === id)
if (ticket) await setTicketStatus(ticket, status)
}
function onBoardTicketClick(t) {
if (suppressTicketClick) return
openTicket(t)
}
async function copyText(text) {
try {
await navigator.clipboard.writeText(text)
@@ -775,15 +841,34 @@
</div>
</div>
<div class="grid cols-3">
{#each [['todo', 'OPEN'], ['in_progress', 'WIP'], ['done', 'DONE']] as [st, label]}
<div class="stack">
{#each boardStatuses as [st, label]}
<div
class="board-col stack"
class:drag-over={dragOverStatus === st}
ondragover={(e) => onColumnDragOver(e, st)}
ondragleave={(e) => onColumnDragLeave(e, st)}
ondrop={(e) => onColumnDrop(e, st)}
role="list"
aria-label={label}
>
<h3>{label}</h3>
{#each byStatus(st) as t}
<button class="ticket" onclick={() => openTicket(t)}>
<span class="badge {t.status}">{label}</span>
<div>{t.title}</div>
{#if t.externalId}<div class="muted">{t.externalId}</div>{/if}
</button>
<div
class="ticket"
class:dragging={dragTicketId === t.id}
draggable="true"
role="listitem"
ondragstart={(e) => onTicketDragStart(e, t)}
ondragend={onTicketDragEnd}
>
<button type="button" class="ticket-open" onclick={() => onBoardTicketClick(t)}>
<div>{t.title}</div>
{#if t.externalId}<div class="muted">{t.externalId}</div>{/if}
</button>
<span class="drag-handle" title="Ziehen" aria-hidden="true">⠿</span>
</div>
{:else}
<div class="board-empty muted">Drop hier</div>
{/each}
</div>
{/each}
+49 -5
View File
@@ -350,20 +350,38 @@ code {
.error { color: var(--danger); margin: 0.5rem 0; }
.row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
.stack { display: grid; gap: 0.6rem; }
.board-col {
min-height: 8rem;
padding: 0.55rem;
border-radius: 8px;
border: 1px dashed transparent;
transition: border-color 0.15s ease, background 0.15s ease;
}
.board-col.drag-over {
border-color: color-mix(in srgb, var(--accent) 70%, var(--line));
background: color-mix(in srgb, var(--accent) 10%, transparent);
}
.board-empty {
padding: 0.75rem 0.4rem;
font-size: 0.85rem;
text-align: center;
border: 1px dashed var(--line);
border-radius: 6px;
}
.ticket {
border: none;
border-radius: 0;
padding: 0.65rem 0.85rem;
border-radius: 6px;
padding: 0.55rem 0.7rem 0.4rem;
background: color-mix(in srgb, var(--panel-2) 55%, transparent);
cursor: pointer;
cursor: grab;
text-align: left;
width: 100%;
color: var(--ink);
font-weight: 500;
display: grid;
gap: 0.25rem;
}
.ticket:hover {
border: none;
color: var(--ink);
background: linear-gradient(
to right,
color-mix(in srgb, var(--accent) 35%, transparent) 0%,
@@ -371,6 +389,32 @@ code {
color-mix(in srgb, var(--panel-2) 70%, transparent) 100%
);
}
.ticket.dragging {
opacity: 0.45;
cursor: grabbing;
}
.ticket-open {
display: grid;
gap: 0.15rem;
width: 100%;
padding: 0.05rem 0;
text-align: left;
color: inherit;
font-weight: inherit;
background: transparent;
}
.ticket-open:hover {
color: inherit;
background: transparent;
}
.drag-handle {
justify-self: end;
color: var(--muted);
font-size: 0.95rem;
letter-spacing: -0.08em;
line-height: 1;
user-select: none;
}
.badge {
display: inline-block;
font-size: 0.75rem;