Files
KanbanGit/resources/js/pages/projects/Board.vue
T
Twyco ff796aa10a
linter / quality (pull_request) Successful in 1m28s
tests / ci (8.4) (pull_request) Successful in 1m47s
feat: overhaul project workspace navigation
2026-07-18 17:52:25 +02:00

659 lines
25 KiB
Vue

<script setup lang="ts">
import { Head, Link, router } from '@inertiajs/vue3';
import { CircleUserRound, GripVertical, Plus } from 'lucide-vue-next';
import { onBeforeUnmount, ref } from 'vue';
import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController';
import ProjectTabs from '@/components/projects/ProjectTabs.vue';
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
import { Badge } from '@/components/ui/badge';
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 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 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,
};
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
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();
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;
}
const targetElement = event.currentTarget as HTMLElement;
if (targetElement.hasPointerCapture(event.pointerId)) {
targetElement.releasePointerCapture(event.pointerId);
}
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();
};
onBeforeUnmount(() => {
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-4 overflow-hidden"
>
<header class="flex items-end justify-between gap-4">
<div class="min-w-0">
<div class="flex items-center gap-2">
<Badge variant="outline" class="rounded px-2.5 py-1">
{{ project.key }}
</Badge>
<span class="text-muted-foreground text-xs font-medium">
Project board
</span>
</div>
<h1 class="mt-2 truncate text-2xl font-bold tracking-tight">
{{ project.name }}
</h1>
</div>
<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>
</header>
<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="-mx-4 flex min-h-[max(28rem,calc(100dvh-14rem))] flex-1 snap-x snap-proximity items-stretch gap-3 overflow-x-auto px-4 pb-3 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8"
>
<section
v-for="boardColumn in boardColumns"
:key="boardColumn.id"
:data-board-column-id="boardColumn.id"
:class="[
'bg-surface-container-lowest 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-primary bg-primary/8 ring-primary/25 ring-2'
: 'border-primary/70 bg-primary/5 ring-primary/10 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>
<div
v-if="
draggedTicket !== null &&
allowedStatusesForColumn(
draggedTicket.ticket,
boardColumn.id,
).length > 1
"
class="mt-3 grid gap-1.5"
>
<div
v-for="ticketStatus in allowedStatusesForColumn(
draggedTicket.ticket,
boardColumn.id,
)"
:key="ticketStatus.id"
:data-ticket-status-id="ticketStatus.id"
:class="[
'rounded border border-dashed px-3 py-2 text-center text-xs font-semibold transition-colors',
isActiveDropTarget(
boardColumn.id,
ticketStatus.id,
)
? 'border-primary bg-primary text-primary-foreground'
: 'border-primary/60 bg-primary/8 text-primary',
]"
>
Drop as {{ ticketStatus.name }}
</div>
</div>
</header>
<div class="flex flex-1 flex-col gap-2.5 p-2.5">
<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'
: '',
]"
@pointerdown="
handlePointerDown(
$event,
ticket,
boardColumn,
)
"
@pointermove="handlePointerMove"
@pointerup="finishPointer"
@pointercancel="cancelPointer"
>
<Link
:href="
showTicket({
project: project.id,
ticket: ticket.id,
})
"
class="block touch-pan-y select-none p-3.5"
draggable="false"
@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>