fix: polish project workspace interactions
linter / quality (pull_request) Successful in 1m21s
tests / ci (8.4) (pull_request) Successful in 1m44s

This commit is contained in:
2026-07-18 18:49:23 +02:00
parent ff796aa10a
commit 5f917e93eb
6 changed files with 185 additions and 110 deletions
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { Badge } from '@/components/ui/badge';
import type { ProjectPageHeaderProps } from '@/types/project-navigation';
defineProps<ProjectPageHeaderProps>();
</script>
<template>
<header
class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between"
>
<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">
{{ context }}
</span>
</div>
<h1 class="mt-2 truncate text-2xl font-bold tracking-tight">
{{ project.name }}
</h1>
<p class="text-muted-foreground mt-1 max-w-3xl text-sm leading-6">
{{ description }}
</p>
</div>
<div class="flex shrink-0 flex-wrap gap-2 self-start sm:self-auto">
<slot />
</div>
</header>
</template>
@@ -14,24 +14,6 @@ defineProps<ProjectTabsProps>();
aria-label="Project sections" aria-label="Project sections"
class="border-outline-variant/70 flex min-w-max items-center gap-1 border-b" class="border-outline-variant/70 flex min-w-max items-center gap-1 border-b"
> >
<Link
:href="boardIndex(project.id)"
prefetch
:class="[
'group relative inline-flex h-11 items-center gap-2 px-3 text-sm font-semibold transition-colors',
active === 'board'
? 'text-primary'
: 'text-muted-foreground hover:text-foreground',
]"
>
<KanbanSquare class="size-4" />
Board
<span
v-if="active === 'board'"
class="bg-primary absolute inset-x-2 -bottom-px h-0.5"
/>
</Link>
<Link <Link
:href="backlogIndex(project.id)" :href="backlogIndex(project.id)"
prefetch prefetch
@@ -50,6 +32,24 @@ defineProps<ProjectTabsProps>();
/> />
</Link> </Link>
<Link
:href="boardIndex(project.id)"
prefetch
:class="[
'group relative inline-flex h-11 items-center gap-2 px-3 text-sm font-semibold transition-colors',
active === 'board'
? 'text-primary'
: 'text-muted-foreground hover:text-foreground',
]"
>
<KanbanSquare class="size-4" />
Board
<span
v-if="active === 'board'"
class="bg-primary absolute inset-x-2 -bottom-px h-0.5"
/>
</Link>
<Link <Link
v-if="canManageProject" v-if="canManageProject"
:href="settingsIndex(project.id)" :href="settingsIndex(project.id)"
+20 -28
View File
@@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, Head, Link } from '@inertiajs/vue3'; import { Form, Head, Link } from '@inertiajs/vue3';
import { Plus } from 'lucide-vue-next';
import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController'; import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController';
import Heading from '@/components/Heading.vue'; import ProjectPageHeader from '@/components/projects/ProjectPageHeader.vue';
import ProjectTabs from '@/components/projects/ProjectTabs.vue'; import ProjectTabs from '@/components/projects/ProjectTabs.vue';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -72,34 +73,25 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
<Head :title="`${project.name} backlog`" /> <Head :title="`${project.name} backlog`" />
<div class="app-page max-w-none gap-5"> <div class="app-page max-w-none gap-5">
<div <ProjectPageHeader
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between" :project="project"
context="Project backlog"
description="Plan and refine work before moving it onto the board."
> >
<div class="space-y-2"> <Button v-if="canCreateTickets" as-child size="sm">
<Badge variant="outline" class="rounded px-2.5 py-1"> <Link
{{ project.key }} :href="
</Badge> createTicket(
<Heading { project: project.id },
:title="project.name" { query: { source: 'backlog' } },
description="Plan and refine work before moving it onto the board." )
/> "
</div> >
<Plus class="size-4" />
<div class="flex flex-wrap gap-3"> Create ticket
<Button v-if="canCreateTickets" as-child> </Link>
<Link </Button>
:href=" </ProjectPageHeader>
createTicket(
{ project: project.id },
{ query: { source: 'backlog' } },
)
"
>
Create ticket
</Link>
</Button>
</div>
</div>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<ProjectTabs <ProjectTabs
+100 -46
View File
@@ -1,11 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, Link, router } from '@inertiajs/vue3'; import { Head, Link, router } from '@inertiajs/vue3';
import { CircleUserRound, GripVertical, Plus } from 'lucide-vue-next'; import { CircleUserRound, GripVertical, Plus } from 'lucide-vue-next';
import { onBeforeUnmount, ref } from 'vue'; import { onBeforeUnmount, onMounted, ref } from 'vue';
import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController'; import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController';
import ProjectPageHeader from '@/components/projects/ProjectPageHeader.vue';
import ProjectTabs from '@/components/projects/ProjectTabs.vue'; import ProjectTabs from '@/components/projects/ProjectTabs.vue';
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue'; import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { index as projectIndex } from '@/routes/projects'; import { index as projectIndex } from '@/routes/projects';
@@ -53,6 +53,7 @@ const pointerX = ref(0);
const pointerY = ref(0); const pointerY = ref(0);
let autoScrollFrame: number | undefined; let autoScrollFrame: number | undefined;
let initialScrollFrame: number | undefined;
let longPressTimer: number | undefined; let longPressTimer: number | undefined;
let suppressClickTicketId: number | null = null; let suppressClickTicketId: number | null = null;
@@ -96,6 +97,9 @@ const isActiveDropTarget = (
); );
}; };
const dropZoneRows = (ticket: TicketModel, boardColumnId: number): string =>
`repeat(${allowedStatusesForColumn(ticket, boardColumnId).length}, minmax(0, 1fr))`;
const clearLongPressTimer = (): void => { const clearLongPressTimer = (): void => {
if (longPressTimer !== undefined) { if (longPressTimer !== undefined) {
window.clearTimeout(longPressTimer); window.clearTimeout(longPressTimer);
@@ -221,7 +225,11 @@ const handlePointerDown = (
startY: event.clientY, startY: event.clientY,
}; };
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId); window.addEventListener('pointermove', handlePointerMove, {
passive: false,
});
window.addEventListener('pointerup', finishPointer);
window.addEventListener('pointercancel', cancelPointer);
if (event.pointerType === 'touch') { if (event.pointerType === 'touch') {
longPressTimer = window.setTimeout(startDragging, 240); longPressTimer = window.setTimeout(startDragging, 240);
@@ -268,6 +276,9 @@ const handlePointerMove = (event: PointerEvent): void => {
const resetDragState = (): void => { const resetDragState = (): void => {
clearLongPressTimer(); clearLongPressTimer();
stopAutoScroll(); stopAutoScroll();
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', finishPointer);
window.removeEventListener('pointercancel', cancelPointer);
pendingDragTicket.value = null; pendingDragTicket.value = null;
draggedTicket.value = null; draggedTicket.value = null;
dragPointer.value = null; dragPointer.value = null;
@@ -311,11 +322,6 @@ const finishPointer = (event: PointerEvent): void => {
return; return;
} }
const targetElement = event.currentTarget as HTMLElement;
if (targetElement.hasPointerCapture(event.pointerId)) {
targetElement.releasePointerCapture(event.pointerId);
}
resetDragState(); resetDragState();
if (drag !== null) { if (drag !== null) {
@@ -347,7 +353,21 @@ const handleTicketClick = (event: MouseEvent, ticketId: number): void => {
event.stopPropagation(); event.stopPropagation();
}; };
onMounted(() => {
initialScrollFrame = window.requestAnimationFrame(() => {
if (boardScroller.value !== null) {
boardScroller.value.scrollLeft = 0;
}
initialScrollFrame = undefined;
});
});
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (initialScrollFrame !== undefined) {
window.cancelAnimationFrame(initialScrollFrame);
}
resetDragState(); resetDragState();
}); });
</script> </script>
@@ -357,23 +377,13 @@ onBeforeUnmount(() => {
<Head :title="`${project.name} board`" /> <Head :title="`${project.name} board`" />
<div <div
class="app-page min-h-[calc(100dvh-3.5rem)] max-w-none gap-4 overflow-hidden" class="app-page min-h-[calc(100dvh-3.5rem)] max-w-none gap-5 overflow-hidden"
> >
<header class="flex items-end justify-between gap-4"> <ProjectPageHeader
<div class="min-w-0"> :project="project"
<div class="flex items-center gap-2"> context="Project board"
<Badge variant="outline" class="rounded px-2.5 py-1"> description="Move tickets through the project's active workflow."
{{ 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"> <Button v-if="canCreateTickets" as-child size="sm">
<Link <Link
:href=" :href="
@@ -387,7 +397,7 @@ onBeforeUnmount(() => {
Create ticket Create ticket
</Link> </Link>
</Button> </Button>
</header> </ProjectPageHeader>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<ProjectTabs <ProjectTabs
@@ -429,20 +439,20 @@ onBeforeUnmount(() => {
<div <div
v-else v-else
ref="boardScroller" 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" 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 <section
v-for="boardColumn in boardColumns" v-for="boardColumn in boardColumns"
:key="boardColumn.id" :key="boardColumn.id"
:data-board-column-id="boardColumn.id" :data-board-column-id="boardColumn.id"
:class="[ :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]', '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 draggedTicket === null
? 'border-outline-variant/70' ? 'border-outline-variant/70'
: isAllowedColumn(boardColumn.id) : isAllowedColumn(boardColumn.id)
? isActiveDropTarget(boardColumn.id) ? isActiveDropTarget(boardColumn.id)
? 'border-primary bg-primary/8 ring-primary/25 ring-2' ? 'border-tertiary bg-tertiary/5 ring-tertiary/30 shadow-lg ring-2'
: 'border-primary/70 bg-primary/5 ring-primary/10 ring-1' : 'border-primary/80 bg-primary/3 ring-primary/15 ring-1'
: 'border-outline-variant/40 bg-muted/40 opacity-45 grayscale', : 'border-outline-variant/40 bg-muted/40 opacity-45 grayscale',
]" ]"
> >
@@ -463,16 +473,24 @@ onBeforeUnmount(() => {
{{ boardColumn.tickets?.length ?? 0 }} {{ boardColumn.tickets?.length ?? 0 }}
</Badge> </Badge>
</div> </div>
</header>
<div class="relative flex flex-1 flex-col gap-2.5 p-2.5">
<div <div
v-if=" v-if="
draggedTicket !== null && draggedTicket !== null &&
allowedStatusesForColumn( allowedStatusesForColumn(
draggedTicket.ticket, draggedTicket.ticket,
boardColumn.id, boardColumn.id,
).length > 1 ).length > 0
" "
class="mt-3 grid gap-1.5" class="absolute inset-2.5 z-20 grid gap-2"
:style="{
gridTemplateRows: dropZoneRows(
draggedTicket.ticket,
boardColumn.id,
),
}"
> >
<div <div
v-for="ticketStatus in allowedStatusesForColumn( v-for="ticketStatus in allowedStatusesForColumn(
@@ -482,21 +500,19 @@ onBeforeUnmount(() => {
:key="ticketStatus.id" :key="ticketStatus.id"
:data-ticket-status-id="ticketStatus.id" :data-ticket-status-id="ticketStatus.id"
:class="[ :class="[
'rounded border border-dashed px-3 py-2 text-center text-xs font-semibold transition-colors', '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( isActiveDropTarget(
boardColumn.id, boardColumn.id,
ticketStatus.id, ticketStatus.id,
) )
? 'border-primary bg-primary text-primary-foreground' ? 'board-drop-zone-active border-tertiary text-on-tertiary-container ring-tertiary/30 dark:text-tertiary-fixed ring-2'
: 'border-primary/60 bg-primary/8 text-primary', : 'border-primary/70 text-on-primary-container dark:text-primary-fixed',
]" ]"
> >
Drop as {{ ticketStatus.name }} Drop as {{ ticketStatus.name }}
</div> </div>
</div> </div>
</header>
<div class="flex flex-1 flex-col gap-2.5 p-2.5">
<div <div
v-if="(boardColumn.tickets?.length ?? 0) === 0" 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" class="text-muted-foreground flex min-h-28 flex-1 items-center justify-center rounded border border-dashed p-4 text-center text-xs"
@@ -525,16 +541,6 @@ onBeforeUnmount(() => {
? 'pointer-events-none animate-pulse opacity-55' ? 'pointer-events-none animate-pulse opacity-55'
: '', : '',
]" ]"
@pointerdown="
handlePointerDown(
$event,
ticket,
boardColumn,
)
"
@pointermove="handlePointerMove"
@pointerup="finishPointer"
@pointercancel="cancelPointer"
> >
<Link <Link
:href=" :href="
@@ -545,6 +551,13 @@ onBeforeUnmount(() => {
" "
class="block touch-pan-y select-none p-3.5" class="block touch-pan-y select-none p-3.5"
draggable="false" draggable="false"
@pointerdown="
handlePointerDown(
$event,
ticket,
boardColumn,
)
"
@click=" @click="
handleTicketClick($event, ticket.id) handleTicketClick($event, ticket.id)
" "
@@ -656,3 +669,44 @@ onBeforeUnmount(() => {
</Teleport> </Teleport>
</AppLayout> </AppLayout>
</template> </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>
+7 -18
View File
@@ -10,8 +10,8 @@ import {
Tags, Tags,
} from 'lucide-vue-next'; } from 'lucide-vue-next';
import { computed } from 'vue'; import { computed } from 'vue';
import ProjectPageHeader from '@/components/projects/ProjectPageHeader.vue';
import ProjectTabs from '@/components/projects/ProjectTabs.vue'; import ProjectTabs from '@/components/projects/ProjectTabs.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { index as projectsIndex } from '@/routes/projects'; import { index as projectsIndex } from '@/routes/projects';
@@ -134,29 +134,18 @@ const sectionHref = (section: ProjectSettingsSection) =>
<Head :title="`${project.name} settings`" /> <Head :title="`${project.name} settings`" />
<div class="app-page max-w-none gap-5"> <div class="app-page max-w-none gap-5">
<header <ProjectPageHeader
class="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between" :project="project"
context="Project settings"
description="Project configuration and access controls."
> >
<div class="space-y-2">
<Badge variant="outline" class="rounded px-2.5 py-1">
{{ project.key }}
</Badge>
<div>
<h1 class="text-2xl font-bold tracking-tight">
{{ project.name }}
</h1>
<p class="text-muted-foreground mt-1 text-sm">
Project configuration and access controls.
</p>
</div>
</div>
<Button <Button
v-if=" v-if="
activeSection === 'general' && activeSection === 'general' &&
$page.props.auth.permissions.projectCreate $page.props.auth.permissions.projectCreate
" "
as-child as-child
size="sm"
variant="outline" variant="outline"
> >
<Link :href="projectTemplateCreate(project.id)"> <Link :href="projectTemplateCreate(project.id)">
@@ -164,7 +153,7 @@ const sectionHref = (section: ProjectSettingsSection) =>
Save as template Save as template
</Link> </Link>
</Button> </Button>
</header> </ProjectPageHeader>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<ProjectTabs <ProjectTabs
+6
View File
@@ -16,6 +16,12 @@ export type ProjectTabsProps = {
project: ProjectModel; project: ProjectModel;
}; };
export type ProjectPageHeaderProps = {
context: string;
description: string;
project: ProjectModel;
};
export type EmbeddedProjectSettingsSectionProps<T> = Omit< export type EmbeddedProjectSettingsSectionProps<T> = Omit<
T, T,
'embedded' | 'project' | 'status' 'embedded' | 'project' | 'status'