Allow dragging tickets between OPEN, WIP, and DONE on the project board.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+92
-7
@@ -53,6 +53,15 @@
|
|||||||
let editTimeEnd = $state('')
|
let editTimeEnd = $state('')
|
||||||
let tokenName = $state('tui')
|
let tokenName = $state('tui')
|
||||||
let tokenScope = $state('write')
|
let tokenScope = $state('write')
|
||||||
|
let dragTicketId = $state('')
|
||||||
|
let dragOverStatus = $state('')
|
||||||
|
let suppressTicketClick = false
|
||||||
|
|
||||||
|
const boardStatuses = [
|
||||||
|
['todo', 'OPEN'],
|
||||||
|
['in_progress', 'WIP'],
|
||||||
|
['done', 'DONE'],
|
||||||
|
]
|
||||||
|
|
||||||
function desiredHash() {
|
function desiredHash() {
|
||||||
return buildHash({
|
return buildHash({
|
||||||
@@ -591,6 +600,63 @@
|
|||||||
return tickets.filter((t) => t.status === status)
|
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) {
|
async function copyText(text) {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text)
|
await navigator.clipboard.writeText(text)
|
||||||
@@ -775,15 +841,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid cols-3">
|
<div class="grid cols-3">
|
||||||
{#each [['todo', 'OPEN'], ['in_progress', 'WIP'], ['done', 'DONE']] as [st, label]}
|
{#each boardStatuses as [st, label]}
|
||||||
<div class="stack">
|
<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>
|
<h3>{label}</h3>
|
||||||
{#each byStatus(st) as t}
|
{#each byStatus(st) as t}
|
||||||
<button class="ticket" onclick={() => openTicket(t)}>
|
<div
|
||||||
<span class="badge {t.status}">{label}</span>
|
class="ticket"
|
||||||
<div>{t.title}</div>
|
class:dragging={dragTicketId === t.id}
|
||||||
{#if t.externalId}<div class="muted">{t.externalId}</div>{/if}
|
draggable="true"
|
||||||
</button>
|
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}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
+49
-5
@@ -350,20 +350,38 @@ code {
|
|||||||
.error { color: var(--danger); margin: 0.5rem 0; }
|
.error { color: var(--danger); margin: 0.5rem 0; }
|
||||||
.row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
|
.row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
|
||||||
.stack { display: grid; gap: 0.6rem; }
|
.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 {
|
.ticket {
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0;
|
border-radius: 6px;
|
||||||
padding: 0.65rem 0.85rem;
|
padding: 0.55rem 0.7rem 0.4rem;
|
||||||
background: color-mix(in srgb, var(--panel-2) 55%, transparent);
|
background: color-mix(in srgb, var(--panel-2) 55%, transparent);
|
||||||
cursor: pointer;
|
cursor: grab;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
.ticket:hover {
|
.ticket:hover {
|
||||||
border: none;
|
|
||||||
color: var(--ink);
|
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
to right,
|
to right,
|
||||||
color-mix(in srgb, var(--accent) 35%, transparent) 0%,
|
color-mix(in srgb, var(--accent) 35%, transparent) 0%,
|
||||||
@@ -371,6 +389,32 @@ code {
|
|||||||
color-mix(in srgb, var(--panel-2) 70%, transparent) 100%
|
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 {
|
.badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user