WIP: Redesign app layout chrome #105
@@ -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)"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user