Files
KanbanGit/resources/js/pages/projects/Board.vue
T
Twyco 5f917e93eb
linter / quality (pull_request) Successful in 1m21s
tests / ci (8.4) (pull_request) Successful in 1m44s
fix: polish project workspace interactions
2026-07-18 18:49:23 +02:00

713 lines
26 KiB
Vue

<script setup lang="ts">
import { Head, Link, router } from '@inertiajs/vue3';
import { CircleUserRound, GripVertical, Plus } from 'lucide-vue-next';
import { onBeforeUnmount, onMounted, ref } from 'vue';
import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController';
import ProjectPageHeader from '@/components/projects/ProjectPageHeader.vue';
import ProjectTabs from '@/components/projects/ProjectTabs.vue';
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
import { Button } from '@/components/ui/button';
import AppLayout from '@/layouts/AppLayout.vue';
import { index as projectIndex } from '@/routes/projects';
import { index as boardIndex } from '@/routes/projects/board';
import { index as settingsIndex } from '@/routes/projects/settings';
import {
create as createTicket,
show as showTicket,
} from '@/routes/projects/tickets';
import type {
BoardDragPointer,
BoardDragTicket,
BoardDropTarget,
} from '@/types/board-drag';
import type { BoardColumnModel } from '@/types/models/board-column-model';
import type { TicketModel } from '@/types/models/ticket-model';
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
import type { BreadcrumbItem, ProjectBoardPageProps } from '@/types';
const props = defineProps<ProjectBoardPageProps>();
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Projects',
href: projectIndex(),
},
{
title: props.project.name,
href: boardIndex(props.project.id),
},
{
title: 'Board',
href: boardIndex(props.project.id),
},
];
const boardScroller = ref<HTMLElement | null>(null);
const pendingDragTicket = ref<BoardDragTicket | null>(null);
const draggedTicket = ref<BoardDragTicket | null>(null);
const dragPointer = ref<BoardDragPointer | null>(null);
const dropTarget = ref<BoardDropTarget | null>(null);
const movingTicketId = ref<number | null>(null);
const moveError = ref<string | null>(null);
const pointerX = ref(0);
const pointerY = ref(0);
let autoScrollFrame: number | undefined;
let initialScrollFrame: number | undefined;
let longPressTimer: number | undefined;
let suppressClickTicketId: number | null = null;
const allowedBoardTargetStatuses = (ticket: TicketModel): TicketStatusModel[] =>
(ticket.ticket_status?.allowed_target_statuses ?? []).filter(
(ticketStatus) =>
ticketStatus.id !== ticket.ticket_status_id &&
ticketStatus.board_column_id !== null,
);
const allowedStatusesForColumn = (
ticket: TicketModel,
boardColumnId: number,
): TicketStatusModel[] =>
allowedBoardTargetStatuses(ticket).filter(
(ticketStatus) => ticketStatus.board_column_id === boardColumnId,
);
const isAllowedColumn = (boardColumnId: number): boolean => {
if (draggedTicket.value === null) {
return false;
}
return (
allowedStatusesForColumn(draggedTicket.value.ticket, boardColumnId)
.length > 0
);
};
const isActiveDropTarget = (
boardColumnId: number,
ticketStatusId?: number,
): boolean => {
if (dropTarget.value?.boardColumnId !== boardColumnId) {
return false;
}
return (
ticketStatusId === undefined ||
dropTarget.value.ticketStatusId === ticketStatusId
);
};
const dropZoneRows = (ticket: TicketModel, boardColumnId: number): string =>
`repeat(${allowedStatusesForColumn(ticket, boardColumnId).length}, minmax(0, 1fr))`;
const clearLongPressTimer = (): void => {
if (longPressTimer !== undefined) {
window.clearTimeout(longPressTimer);
longPressTimer = undefined;
}
};
const stopAutoScroll = (): void => {
if (autoScrollFrame !== undefined) {
window.cancelAnimationFrame(autoScrollFrame);
autoScrollFrame = undefined;
}
};
const runAutoScroll = (): void => {
const scroller = boardScroller.value;
if (draggedTicket.value === null || scroller === null) {
stopAutoScroll();
return;
}
const bounds = scroller.getBoundingClientRect();
const edgeSize = Math.min(88, bounds.width * 0.18);
const maximumStep = 20;
if (pointerX.value < bounds.left + edgeSize) {
const intensity =
1 - Math.max(0, pointerX.value - bounds.left) / edgeSize;
scroller.scrollLeft -= Math.max(5, maximumStep * intensity);
} else if (pointerX.value > bounds.right - edgeSize) {
const intensity =
1 - Math.max(0, bounds.right - pointerX.value) / edgeSize;
scroller.scrollLeft += Math.max(5, maximumStep * intensity);
}
autoScrollFrame = window.requestAnimationFrame(runAutoScroll);
};
const startDragging = (): void => {
if (pendingDragTicket.value === null || draggedTicket.value !== null) {
return;
}
clearLongPressTimer();
draggedTicket.value = pendingDragTicket.value;
suppressClickTicketId = pendingDragTicket.value.ticket.id;
document.body.classList.add('select-none');
autoScrollFrame = window.requestAnimationFrame(runAutoScroll);
};
const updateDropTarget = (clientX: number, clientY: number): void => {
const drag = draggedTicket.value;
const element = document.elementFromPoint(clientX, clientY);
const columnElement = element?.closest<HTMLElement>(
'[data-board-column-id]',
);
if (
drag === null ||
columnElement === undefined ||
columnElement === null
) {
dropTarget.value = null;
return;
}
const boardColumnId = Number(columnElement.dataset.boardColumnId);
const allowedStatuses = allowedStatusesForColumn(
drag.ticket,
boardColumnId,
);
const statusElement = element?.closest<HTMLElement>(
'[data-ticket-status-id]',
);
const statusId = Number(statusElement?.dataset.ticketStatusId);
if (
statusElement !== undefined &&
statusElement !== null &&
allowedStatuses.some((ticketStatus) => ticketStatus.id === statusId)
) {
dropTarget.value = {
boardColumnId,
ticketStatusId: statusId,
};
return;
}
dropTarget.value =
allowedStatuses.length === 1
? {
boardColumnId,
ticketStatusId: allowedStatuses[0].id,
}
: null;
};
const handlePointerDown = (
event: PointerEvent,
ticket: TicketModel,
boardColumn: BoardColumnModel,
): void => {
if (
!props.canEditTickets ||
movingTicketId.value !== null ||
event.button !== 0 ||
allowedBoardTargetStatuses(ticket).length === 0
) {
return;
}
pointerX.value = event.clientX;
pointerY.value = event.clientY;
pendingDragTicket.value = {
originColumnId: boardColumn.id,
ticket,
};
dragPointer.value = {
pointerId: event.pointerId,
pointerType: event.pointerType,
startX: event.clientX,
startY: event.clientY,
};
window.addEventListener('pointermove', handlePointerMove, {
passive: false,
});
window.addEventListener('pointerup', finishPointer);
window.addEventListener('pointercancel', cancelPointer);
if (event.pointerType === 'touch') {
longPressTimer = window.setTimeout(startDragging, 240);
}
};
const handlePointerMove = (event: PointerEvent): void => {
const pointer = dragPointer.value;
if (pointer === null || pointer.pointerId !== event.pointerId) {
return;
}
pointerX.value = event.clientX;
pointerY.value = event.clientY;
const distance = Math.hypot(
event.clientX - pointer.startX,
event.clientY - pointer.startY,
);
if (draggedTicket.value === null) {
if (pointer.pointerType === 'touch') {
if (distance > 10) {
clearLongPressTimer();
}
return;
}
if (distance < 7) {
return;
}
startDragging();
}
if (draggedTicket.value !== null) {
event.preventDefault();
updateDropTarget(event.clientX, event.clientY);
}
};
const resetDragState = (): void => {
clearLongPressTimer();
stopAutoScroll();
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', finishPointer);
window.removeEventListener('pointercancel', cancelPointer);
pendingDragTicket.value = null;
draggedTicket.value = null;
dragPointer.value = null;
dropTarget.value = null;
document.body.classList.remove('select-none');
};
const moveTicket = (drag: BoardDragTicket, target: BoardDropTarget): void => {
moveError.value = null;
movingTicketId.value = drag.ticket.id;
router.patch(
ProjectBoardController.move.url({
project: props.project.id,
ticket: drag.ticket.id,
}),
{
redirect_to: 'board',
ticket_status_id: target.ticketStatusId,
},
{
onError: (errors) => {
moveError.value =
Object.values(errors)[0] ??
'The ticket could not be moved. Please try again.';
},
onFinish: () => {
movingTicketId.value = null;
},
preserveScroll: true,
},
);
};
const finishPointer = (event: PointerEvent): void => {
const pointer = dragPointer.value;
const drag = draggedTicket.value;
const target = dropTarget.value;
if (pointer === null || pointer.pointerId !== event.pointerId) {
return;
}
resetDragState();
if (drag !== null) {
window.setTimeout(() => {
suppressClickTicketId = null;
}, 300);
if (target !== null) {
moveTicket(drag, target);
}
}
};
const cancelPointer = (event: PointerEvent): void => {
if (dragPointer.value?.pointerId !== event.pointerId) {
return;
}
resetDragState();
suppressClickTicketId = null;
};
const handleTicketClick = (event: MouseEvent, ticketId: number): void => {
if (suppressClickTicketId !== ticketId) {
return;
}
event.preventDefault();
event.stopPropagation();
};
onMounted(() => {
initialScrollFrame = window.requestAnimationFrame(() => {
if (boardScroller.value !== null) {
boardScroller.value.scrollLeft = 0;
}
initialScrollFrame = undefined;
});
});
onBeforeUnmount(() => {
if (initialScrollFrame !== undefined) {
window.cancelAnimationFrame(initialScrollFrame);
}
resetDragState();
});
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs" :project="project">
<Head :title="`${project.name} board`" />
<div
class="app-page min-h-[calc(100dvh-3.5rem)] max-w-none gap-5 overflow-hidden"
>
<ProjectPageHeader
:project="project"
context="Project board"
description="Move tickets through the project's active workflow."
>
<Button v-if="canCreateTickets" as-child size="sm">
<Link
:href="
createTicket(
{ project: project.id },
{ query: { source: 'board' } },
)
"
>
<Plus class="size-4" />
Create ticket
</Link>
</Button>
</ProjectPageHeader>
<div class="overflow-x-auto">
<ProjectTabs
active="board"
:can-manage-project="canManageProject"
:project="project"
/>
</div>
<div v-if="status" class="status-success">
{{ status }}
</div>
<div
v-if="moveError"
class="rounded border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800 dark:border-red-900 dark:bg-red-950/40 dark:text-red-200"
>
{{ moveError }}
</div>
<div
v-if="boardColumns.length === 0"
class="empty-state flex min-h-[calc(100dvh-15rem)] flex-col items-center justify-center gap-4"
>
<p>The board has no workflow columns yet.</p>
<Button v-if="canManageProject" as-child variant="outline">
<Link
:href="
settingsIndex(project.id, {
query: { section: 'board' },
})
"
>
Configure board
</Link>
</Button>
</div>
<div
v-else
ref="boardScroller"
class="flex min-h-[max(28rem,calc(100dvh-14rem))] min-w-0 flex-1 snap-x snap-proximity items-stretch gap-3 overflow-x-auto overscroll-x-contain pb-3"
>
<section
v-for="boardColumn in boardColumns"
:key="boardColumn.id"
:data-board-column-id="boardColumn.id"
:class="[
'bg-surface-container-lowest relative flex min-h-full w-[min(21rem,calc(100vw-2.5rem))] shrink-0 snap-start flex-col rounded border transition-[border-color,background-color,filter,opacity,box-shadow] duration-150 sm:w-[21rem]',
draggedTicket === null
? 'border-outline-variant/70'
: isAllowedColumn(boardColumn.id)
? isActiveDropTarget(boardColumn.id)
? 'border-tertiary bg-tertiary/5 ring-tertiary/30 shadow-lg ring-2'
: 'border-primary/80 bg-primary/3 ring-primary/15 ring-1'
: 'border-outline-variant/40 bg-muted/40 opacity-45 grayscale',
]"
>
<header
class="bg-surface-container-lowest/95 border-outline-variant/70 sticky top-0 z-10 rounded-t border-b px-3.5 py-3 backdrop-blur"
>
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<h2 class="truncate text-sm font-bold">
{{ boardColumn.name }}
</h2>
<p class="text-muted-foreground mt-0.5 text-xs">
{{ boardColumn.tickets?.length ?? 0 }}
tickets
</p>
</div>
<Badge variant="secondary" class="rounded">
{{ boardColumn.tickets?.length ?? 0 }}
</Badge>
</div>
</header>
<div class="relative flex flex-1 flex-col gap-2.5 p-2.5">
<div
v-if="
draggedTicket !== null &&
allowedStatusesForColumn(
draggedTicket.ticket,
boardColumn.id,
).length > 0
"
class="absolute inset-2.5 z-20 grid gap-2"
:style="{
gridTemplateRows: dropZoneRows(
draggedTicket.ticket,
boardColumn.id,
),
}"
>
<div
v-for="ticketStatus in allowedStatusesForColumn(
draggedTicket.ticket,
boardColumn.id,
)"
:key="ticketStatus.id"
:data-ticket-status-id="ticketStatus.id"
:class="[
'board-drop-zone flex min-h-0 items-center justify-center rounded border-2 border-dashed p-4 text-center text-xs font-bold transition-[border-color,background-color,box-shadow,color] duration-150',
isActiveDropTarget(
boardColumn.id,
ticketStatus.id,
)
? 'board-drop-zone-active border-tertiary text-on-tertiary-container ring-tertiary/30 dark:text-tertiary-fixed ring-2'
: 'border-primary/70 text-on-primary-container dark:text-primary-fixed',
]"
>
Drop as {{ ticketStatus.name }}
</div>
</div>
<div
v-if="(boardColumn.tickets?.length ?? 0) === 0"
class="text-muted-foreground flex min-h-28 flex-1 items-center justify-center rounded border border-dashed p-4 text-center text-xs"
>
No tickets in this column.
</div>
<template v-else>
<article
v-for="ticket in boardColumn.tickets"
:key="ticket.id"
:class="[
'bg-card shadow-xs hover:border-primary/40 group relative rounded border transition-[border-color,box-shadow,opacity,transform] hover:shadow-sm',
ticket.is_blocked
? 'border-amber-300 dark:border-amber-800'
: 'border-outline-variant/70',
canEditTickets &&
allowedBoardTargetStatuses(ticket).length >
0
? 'cursor-grab active:cursor-grabbing'
: '',
draggedTicket?.ticket.id === ticket.id
? 'border-primary scale-[0.99] opacity-30'
: '',
movingTicketId === ticket.id
? 'pointer-events-none animate-pulse opacity-55'
: '',
]"
>
<Link
:href="
showTicket({
project: project.id,
ticket: ticket.id,
})
"
class="block touch-pan-y select-none p-3.5"
draggable="false"
@pointerdown="
handlePointerDown(
$event,
ticket,
boardColumn,
)
"
@click="
handleTicketClick($event, ticket.id)
"
@dragstart.prevent
>
<div
class="flex items-start justify-between gap-2"
>
<div
class="flex flex-wrap items-center gap-1.5"
>
<span
class="text-primary text-[0.68rem] font-bold uppercase tracking-wide"
>
{{ ticket.ticket_key }}
</span>
<Badge
v-if="ticket.is_blocked"
variant="destructive"
class="rounded text-[0.65rem]"
>
Blocked
</Badge>
</div>
<GripVertical
v-if="
canEditTickets &&
allowedBoardTargetStatuses(
ticket,
).length > 0
"
class="text-muted-foreground/50 size-4 shrink-0"
/>
</div>
<h3
class="group-hover:text-primary mt-2 text-sm font-semibold leading-snug transition-colors"
>
{{ ticket.title }}
</h3>
<p
v-if="ticket.description"
class="text-muted-foreground mt-1.5 line-clamp-2 text-xs leading-relaxed"
>
{{ ticket.description }}
</p>
<div
class="border-outline-variant/60 mt-3 flex items-center justify-between gap-3 border-t pt-3"
>
<div
class="flex min-w-0 items-center gap-2"
>
<UserAvatar
v-if="ticket.assignee"
:user="ticket.assignee"
class="size-6"
/>
<span
v-else
class="border-outline-variant text-muted-foreground flex size-6 items-center justify-center rounded-full border border-dashed"
>
<CircleUserRound
class="size-3.5"
/>
</span>
<span
class="text-muted-foreground truncate text-[0.68rem]"
>
{{
ticket.assignee?.name ??
'Unassigned'
}}
</span>
</div>
<div
class="text-muted-foreground flex shrink-0 items-center gap-1.5 text-[0.68rem] font-medium"
>
<span
class="bg-primary size-1.5 rounded-full"
/>
{{ ticket.ticket_priority.name }}
</div>
</div>
</Link>
</article>
</template>
</div>
</section>
</div>
</div>
<Teleport to="body">
<div
v-if="draggedTicket"
class="bg-card border-primary pointer-events-none fixed z-[100] w-72 -translate-x-1/2 -translate-y-1/2 rounded border px-4 py-3 shadow-2xl"
:style="{
left: `${pointerX}px`,
top: `${pointerY}px`,
}"
>
<div class="text-primary text-[0.68rem] font-bold uppercase">
{{ draggedTicket.ticket.ticket_key }}
</div>
<div class="mt-1 line-clamp-2 text-sm font-semibold">
{{ draggedTicket.ticket.title }}
</div>
</div>
</Teleport>
</AppLayout>
</template>
<style scoped>
.board-drop-zone {
background-color: color-mix(
in srgb,
var(--primary) 8%,
var(--surface-container-lowest)
);
background-image:
linear-gradient(
color-mix(in srgb, var(--primary) 12%, transparent) 1px,
transparent 1px
),
linear-gradient(
90deg,
color-mix(in srgb, var(--primary) 12%, transparent) 1px,
transparent 1px
);
background-size: 20px 20px;
}
.board-drop-zone-active {
background-color: color-mix(
in srgb,
var(--tertiary) 14%,
var(--surface-container-lowest)
);
background-image:
linear-gradient(
color-mix(in srgb, var(--tertiary) 20%, transparent) 1px,
transparent 1px
),
linear-gradient(
90deg,
color-mix(in srgb, var(--tertiary) 20%, transparent) 1px,
transparent 1px
);
box-shadow: inset 0 0 0 1px
color-mix(in srgb, var(--tertiary) 22%, transparent);
}
</style>