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"
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
:href="backlogIndex(project.id)"
prefetch
@@ -50,6 +32,24 @@ defineProps<ProjectTabsProps>();
/>
</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
v-if="canManageProject"
:href="settingsIndex(project.id)"
+20 -28
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { Form, Head, Link } from '@inertiajs/vue3';
import { Plus } from 'lucide-vue-next';
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 { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -72,34 +73,25 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
<Head :title="`${project.name} backlog`" />
<div class="app-page max-w-none gap-5">
<div
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
<ProjectPageHeader
:project="project"
context="Project backlog"
description="Plan and refine work before moving it onto the board."
>
<div class="space-y-2">
<Badge variant="outline" class="rounded px-2.5 py-1">
{{ project.key }}
</Badge>
<Heading
:title="project.name"
description="Plan and refine work before moving it onto the board."
/>
</div>
<div class="flex flex-wrap gap-3">
<Button v-if="canCreateTickets" as-child>
<Link
:href="
createTicket(
{ project: project.id },
{ query: { source: 'backlog' } },
)
"
>
Create ticket
</Link>
</Button>
</div>
</div>
<Button v-if="canCreateTickets" as-child size="sm">
<Link
:href="
createTicket(
{ project: project.id },
{ query: { source: 'backlog' } },
)
"
>
<Plus class="size-4" />
Create ticket
</Link>
</Button>
</ProjectPageHeader>
<div class="overflow-x-auto">
<ProjectTabs
+100 -46
View File
@@ -1,11 +1,11 @@
<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 { 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 { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import AppLayout from '@/layouts/AppLayout.vue';
import { index as projectIndex } from '@/routes/projects';
@@ -53,6 +53,7 @@ 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;
@@ -96,6 +97,9 @@ const isActiveDropTarget = (
);
};
const dropZoneRows = (ticket: TicketModel, boardColumnId: number): string =>
`repeat(${allowedStatusesForColumn(ticket, boardColumnId).length}, minmax(0, 1fr))`;
const clearLongPressTimer = (): void => {
if (longPressTimer !== undefined) {
window.clearTimeout(longPressTimer);
@@ -221,7 +225,11 @@ const handlePointerDown = (
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') {
longPressTimer = window.setTimeout(startDragging, 240);
@@ -268,6 +276,9 @@ const handlePointerMove = (event: PointerEvent): void => {
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;
@@ -311,11 +322,6 @@ const finishPointer = (event: PointerEvent): void => {
return;
}
const targetElement = event.currentTarget as HTMLElement;
if (targetElement.hasPointerCapture(event.pointerId)) {
targetElement.releasePointerCapture(event.pointerId);
}
resetDragState();
if (drag !== null) {
@@ -347,7 +353,21 @@ const handleTicketClick = (event: MouseEvent, ticketId: number): void => {
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>
@@ -357,23 +377,13 @@ onBeforeUnmount(() => {
<Head :title="`${project.name} board`" />
<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">
<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>
<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="
@@ -387,7 +397,7 @@ onBeforeUnmount(() => {
Create ticket
</Link>
</Button>
</header>
</ProjectPageHeader>
<div class="overflow-x-auto">
<ProjectTabs
@@ -429,20 +439,20 @@ onBeforeUnmount(() => {
<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"
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 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
? '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-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',
]"
>
@@ -463,16 +473,24 @@ onBeforeUnmount(() => {
{{ 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 > 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
v-for="ticketStatus in allowedStatusesForColumn(
@@ -482,21 +500,19 @@ onBeforeUnmount(() => {
: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',
'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,
)
? 'border-primary bg-primary text-primary-foreground'
: 'border-primary/60 bg-primary/8 text-primary',
? '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>
</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"
@@ -525,16 +541,6 @@ onBeforeUnmount(() => {
? 'pointer-events-none animate-pulse opacity-55'
: '',
]"
@pointerdown="
handlePointerDown(
$event,
ticket,
boardColumn,
)
"
@pointermove="handlePointerMove"
@pointerup="finishPointer"
@pointercancel="cancelPointer"
>
<Link
:href="
@@ -545,6 +551,13 @@ onBeforeUnmount(() => {
"
class="block touch-pan-y select-none p-3.5"
draggable="false"
@pointerdown="
handlePointerDown(
$event,
ticket,
boardColumn,
)
"
@click="
handleTicketClick($event, ticket.id)
"
@@ -656,3 +669,44 @@ onBeforeUnmount(() => {
</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>
+7 -18
View File
@@ -10,8 +10,8 @@ import {
Tags,
} from 'lucide-vue-next';
import { computed } from 'vue';
import ProjectPageHeader from '@/components/projects/ProjectPageHeader.vue';
import ProjectTabs from '@/components/projects/ProjectTabs.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import AppLayout from '@/layouts/AppLayout.vue';
import { index as projectsIndex } from '@/routes/projects';
@@ -134,29 +134,18 @@ const sectionHref = (section: ProjectSettingsSection) =>
<Head :title="`${project.name} settings`" />
<div class="app-page max-w-none gap-5">
<header
class="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between"
<ProjectPageHeader
: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
v-if="
activeSection === 'general' &&
$page.props.auth.permissions.projectCreate
"
as-child
size="sm"
variant="outline"
>
<Link :href="projectTemplateCreate(project.id)">
@@ -164,7 +153,7 @@ const sectionHref = (section: ProjectSettingsSection) =>
Save as template
</Link>
</Button>
</header>
</ProjectPageHeader>
<div class="overflow-x-auto">
<ProjectTabs
+6
View File
@@ -16,6 +16,12 @@ export type ProjectTabsProps = {
project: ProjectModel;
};
export type ProjectPageHeaderProps = {
context: string;
description: string;
project: ProjectModel;
};
export type EmbeddedProjectSettingsSectionProps<T> = Omit<
T,
'embedded' | 'project' | 'status'