713 lines
26 KiB
Vue
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>
|