vikunja/frontend/src/components/project/views/ProjectKanban.vue
kolaente ec1ff80791
fix(kanban): save done and default bucket on the view and not on the project
The frontend was still trying to update the two in the project which won't work since they are now saved at the view level, not the project.
2024-04-06 13:32:54 +02:00

956 lines
26 KiB
Vue

<template>
<ProjectWrapper
class="project-kanban"
:project-id="projectId"
:view-id
>
<template #header>
<div class="filter-container">
<FilterPopup
v-if="!isSavedFilter(project)"
v-model="params"
/>
</div>
</template>
<template #default>
<div class="kanban-view">
<div
:class="{ 'is-loading': loading && !oneTaskUpdating}"
class="kanban kanban-bucket-container loader-container"
>
<draggable
v-bind="DRAG_OPTIONS"
:model-value="buckets"
group="buckets"
:disabled="!canWrite || newTaskInputFocused"
tag="ul"
:item-key="({id}: IBucket) => `bucket${id}`"
:component-data="bucketDraggableComponentData"
@update:modelValue="updateBuckets"
@end="updateBucketPosition"
@start="() => dragBucket = true"
>
<template #item="{element: bucket, index: bucketIndex }">
<div
class="bucket"
:class="{'is-collapsed': collapsedBuckets[bucket.id]}"
>
<div
class="bucket-header"
@click="() => unCollapseBucket(bucket)"
>
<span
v-if="view?.doneBucketId === bucket.id"
v-tooltip="$t('project.kanban.doneBucketHint')"
class="icon is-small has-text-success mr-2"
>
<icon icon="check-double" />
</span>
<h2
class="title input"
:contenteditable="(bucketTitleEditable && canWrite && !collapsedBuckets[bucket.id]) ? true : undefined"
:spellcheck="false"
@keydown.enter.prevent.stop="($event.target as HTMLElement).blur()"
@keydown.esc.prevent.stop="($event.target as HTMLElement).blur()"
@blur="saveBucketTitle(bucket.id, ($event.target as HTMLElement).textContent as string)"
@click="focusBucketTitle"
>
{{ bucket.title }}
</h2>
<span
v-if="bucket.limit > 0"
:class="{'is-max': bucket.count >= bucket.limit}"
class="limit"
>
{{ bucket.count }}/{{ bucket.limit }}
</span>
<Dropdown
v-if="canWrite && !collapsedBuckets[bucket.id]"
class="is-right options"
trigger-icon="ellipsis-v"
@close="() => showSetLimitInput = false"
>
<DropdownItem
@click.stop="showSetLimitInput = true"
>
<div
v-if="showSetLimitInput"
class="field has-addons"
>
<div class="control">
<input
ref="bucketLimitInputRef"
v-focus.always
:value="bucket.limit"
class="input"
type="number"
min="0"
@keyup.esc="() => showSetLimitInput = false"
@keyup.enter="() => {setBucketLimit(bucket.id, true); showSetLimitInput = false}"
@input="setBucketLimit(bucket.id)"
>
</div>
<div class="control">
<x-button
v-cy="'setBucketLimit'"
:disabled="bucket.limit < 0"
:icon="['far', 'save']"
:shadow="false"
@click="setBucketLimit(bucket.id, true)"
/>
</div>
</div>
<template v-else>
{{
$t('project.kanban.limit', {limit: bucket.limit > 0 ? bucket.limit : $t('project.kanban.noLimit')})
}}
</template>
</DropdownItem>
<DropdownItem
v-tooltip="$t('project.kanban.doneBucketHintExtended')"
:icon-class="{'has-text-success': bucket.id === view?.doneBucketId}"
icon="check-double"
@click.stop="toggleDoneBucket(bucket)"
>
{{ $t('project.kanban.doneBucket') }}
</DropdownItem>
<DropdownItem
v-tooltip="$t('project.kanban.defaultBucketHint')"
:icon-class="{'has-text-primary': bucket.id === view?.defaultBucketId}"
icon="th"
@click.stop="toggleDefaultBucket(bucket)"
>
{{ $t('project.kanban.defaultBucket') }}
</DropdownItem>
<DropdownItem
icon="angles-up"
@click.stop="() => collapseBucket(bucket)"
>
{{ $t('project.kanban.collapse') }}
</DropdownItem>
<DropdownItem
v-tooltip="buckets.length <= 1 ? $t('project.kanban.deleteLast') : ''"
:class="{'is-disabled': buckets.length <= 1}"
icon-class="has-text-danger"
icon="trash-alt"
@click.stop="() => deleteBucketModal(bucket.id)"
>
{{ $t('misc.delete') }}
</DropdownItem>
</Dropdown>
</div>
<draggable
v-bind="DRAG_OPTIONS"
:model-value="bucket.tasks"
:group="{name: 'tasks', put: shouldAcceptDrop(bucket) && !dragBucket}"
:disabled="!canWrite"
:data-bucket-index="bucketIndex"
tag="ul"
:item-key="(task: ITask) => `bucket${bucket.id}-task${task.id}`"
:component-data="getTaskDraggableTaskComponentData(bucket)"
@update:modelValue="(tasks) => updateTasks(bucket.id, tasks)"
@start="() => dragstart(bucket)"
@end="updateTaskPosition"
>
<template #footer>
<div
v-if="canWrite"
class="bucket-footer"
>
<div
v-if="showNewTaskInput[bucket.id]"
class="field"
>
<div
class="control"
:class="{'is-loading': loading || taskLoading}"
>
<input
v-model="newTaskText"
v-focus.always
class="input"
:disabled="loading || taskLoading || undefined"
:placeholder="$t('project.kanban.addTaskPlaceholder')"
type="text"
@focusout="toggleShowNewTaskInput(bucket.id)"
@focusin="() => newTaskInputFocused = true"
@keyup.enter="addTaskToBucket(bucket.id)"
@keyup.esc="toggleShowNewTaskInput(bucket.id)"
>
</div>
<p
v-if="newTaskError[bucket.id] && newTaskText === ''"
class="help is-danger"
>
{{ $t('project.create.addTitleRequired') }}
</p>
</div>
<x-button
v-else
class="is-fullwidth has-text-centered"
:shadow="false"
icon="plus"
variant="secondary"
@click="toggleShowNewTaskInput(bucket.id)"
>
{{
bucket.tasks.length === 0 ? $t('project.kanban.addTask') : $t('project.kanban.addAnotherTask')
}}
</x-button>
</div>
</template>
<template #item="{element: task}">
<div class="task-item">
<KanbanCard
class="kanban-card"
:task="task"
:loading="taskUpdating[task.id] ?? false"
/>
</div>
</template>
</draggable>
</div>
</template>
</draggable>
<div
v-if="canWrite && !loading && buckets.length > 0"
class="bucket new-bucket"
>
<input
v-if="showNewBucketInput"
v-model="newBucketTitle"
v-focus.always
:class="{'is-loading': loading}"
:disabled="loading || undefined"
class="input"
:placeholder="$t('project.kanban.addBucketPlaceholder')"
type="text"
@blur="() => showNewBucketInput = false"
@keyup.enter="createNewBucket"
@keyup.esc="($event.target as HTMLInputElement).blur()"
>
<x-button
v-else
:shadow="false"
class="is-transparent is-fullwidth has-text-centered"
variant="secondary"
icon="plus"
@click="() => showNewBucketInput = true"
>
{{ $t('project.kanban.addBucket') }}
</x-button>
</div>
</div>
<modal
:enabled="showBucketDeleteModal"
@close="showBucketDeleteModal = false"
@submit="deleteBucket()"
>
<template #header>
<span>{{ $t('project.kanban.deleteHeaderBucket') }}</span>
</template>
<template #text>
<p>
{{ $t('project.kanban.deleteBucketText1') }}<br>
{{ $t('project.kanban.deleteBucketText2') }}
</p>
</template>
</modal>
</div>
</template>
</ProjectWrapper>
</template>
<script setup lang="ts">
import {computed, nextTick, ref, watch} from 'vue'
import {useI18n} from 'vue-i18n'
import draggable from 'zhyswan-vuedraggable'
import {klona} from 'klona/lite'
import {RIGHTS as Rights} from '@/constants/rights'
import BucketModel from '@/models/bucket'
import type {IBucket} from '@/modelTypes/IBucket'
import type {ITask} from '@/modelTypes/ITask'
import {useBaseStore} from '@/stores/base'
import {useTaskStore} from '@/stores/tasks'
import {useKanbanStore} from '@/stores/kanban'
import ProjectWrapper from '@/components/project/ProjectWrapper.vue'
import FilterPopup from '@/components/project/partials/filter-popup.vue'
import KanbanCard from '@/components/tasks/partials/kanban-card.vue'
import Dropdown from '@/components/misc/dropdown.vue'
import DropdownItem from '@/components/misc/dropdown-item.vue'
import {
type CollapsedBuckets,
getCollapsedBucketState,
saveCollapsedBucketState,
} from '@/helpers/saveCollapsedBucketState'
import {calculateItemPosition} from '@/helpers/calculateItemPosition'
import {isSavedFilter} from '@/services/savedFilter'
import {success} from '@/message'
import {useProjectStore} from '@/stores/projects'
import type {TaskFilterParams} from '@/services/taskCollection'
import type {IProjectView} from '@/modelTypes/IProjectView'
import TaskPositionService from '@/services/taskPosition'
import TaskPositionModel from '@/models/taskPosition'
import {i18n} from '@/i18n'
import ProjectViewService from '@/services/projectViews'
import ProjectViewModel from '@/models/projectView'
const {
projectId,
viewId,
} = defineProps<{
projectId: number,
viewId: IProjectView['id'],
}>()
const DRAG_OPTIONS = {
// sortable options
animation: 150,
ghostClass: 'ghost',
dragClass: 'task-dragging',
delayOnTouchOnly: true,
delay: 150,
} as const
const MIN_SCROLL_HEIGHT_PERCENT = 0.25
const {t} = useI18n({useScope: 'global'})
const baseStore = useBaseStore()
const kanbanStore = useKanbanStore()
const taskStore = useTaskStore()
const projectStore = useProjectStore()
const taskPositionService = ref(new TaskPositionService())
const taskContainerRefs = ref<{ [id: IBucket['id']]: HTMLElement }>({})
const bucketLimitInputRef = ref<HTMLInputElement | null>(null)
const drag = ref(false)
const dragBucket = ref(false)
const sourceBucket = ref(0)
const showBucketDeleteModal = ref(false)
const bucketToDelete = ref(0)
const bucketTitleEditable = ref(false)
const newTaskText = ref('')
const showNewTaskInput = ref<{ [id: IBucket['id']]: boolean }>({})
const newBucketTitle = ref('')
const showNewBucketInput = ref(false)
const newTaskError = ref<{ [id: IBucket['id']]: boolean }>({})
const newTaskInputFocused = ref(false)
const showSetLimitInput = ref(false)
const collapsedBuckets = ref<CollapsedBuckets>({})
// We're using this to show the loading animation only at the task when updating it
const taskUpdating = ref<{ [id: ITask['id']]: boolean }>({})
const oneTaskUpdating = ref(false)
const params = ref<TaskFilterParams>({
sort_by: [],
order_by: [],
filter: '',
filter_include_nulls: false,
s: '',
})
const getTaskDraggableTaskComponentData = computed(() => (bucket: IBucket) => {
return {
ref: (el: HTMLElement) => setTaskContainerRef(bucket.id, el),
onScroll: (event: Event) => handleTaskContainerScroll(bucket.id, event.target as HTMLElement),
type: 'transition-group',
name: !drag.value ? 'move-card' : null,
class: [
'tasks',
{'dragging-disabled': !canWrite.value},
],
}
})
const bucketDraggableComponentData = computed(() => ({
type: 'transition-group',
name: !dragBucket.value ? 'move-bucket' : null,
class: [
'kanban-bucket-container',
{'dragging-disabled': !canWrite.value},
],
}))
const canWrite = computed(() => baseStore.currentProject?.maxRight > Rights.READ)
const project = computed(() => projectId ? projectStore.projects[projectId] : null)
const buckets = computed(() => kanbanStore.buckets)
const loading = computed(() => kanbanStore.isLoading)
const view = computed<IProjectView | null>(() => project.value?.views.find(v => v.id === viewId) || null)
const taskLoading = computed(() => taskStore.isLoading || taskPositionService.value.loading)
watch(
() => ({
params: params.value,
projectId,
viewId,
}),
({params}) => {
if (projectId === undefined || Number(projectId) === 0) {
return
}
collapsedBuckets.value = getCollapsedBucketState(projectId)
kanbanStore.loadBucketsForProject(projectId, viewId, params)
},
{
immediate: true,
deep: true,
},
)
function setTaskContainerRef(id: IBucket['id'], el: HTMLElement) {
if (!el) return
taskContainerRefs.value[id] = el
}
function handleTaskContainerScroll(id: IBucket['id'], el: HTMLElement) {
if (!el) {
return
}
const scrollTopMax = el.scrollHeight - el.clientHeight
const threshold = el.scrollTop + el.scrollTop * MIN_SCROLL_HEIGHT_PERCENT
if (scrollTopMax > threshold) {
return
}
kanbanStore.loadNextTasksForBucket(
projectId,
viewId,
params.value,
id,
)
}
function updateTasks(bucketId: IBucket['id'], tasks: IBucket['tasks']) {
const bucket = kanbanStore.getBucketById(bucketId)
if (bucket === undefined) {
return
}
kanbanStore.setBucketById({
...bucket,
tasks,
})
}
async function updateTaskPosition(e) {
drag.value = false
// While we could just pass the bucket index in through the function call, this would not give us the
// new bucket id when a task has been moved between buckets, only the new bucket. Using the data-bucket-id
// of the drop target works all the time.
const bucketIndex = parseInt(e.to.dataset.bucketIndex)
const newBucket = buckets.value[bucketIndex]
// HACK:
// this is a hacky workaround for a known problem of vue.draggable.next when using the footer slot
// the problem: https://github.com/SortableJS/vue.draggable.next/issues/108
// This hack doesn't remove the problem that the ghost item is still displayed below the footer
// It just makes releasing the item possible.
// The newIndex of the event doesn't count in the elements of the footer slot.
// This is why in case the length of the tasks is identical with the newIndex
// we have to remove 1 to get the correct index.
const newTaskIndex = newBucket.tasks.length === e.newIndex
? e.newIndex - 1
: e.newIndex
const task = newBucket.tasks[newTaskIndex]
const oldBucket = buckets.value.find(b => b.id === task.bucketId)
const taskBefore = newBucket.tasks[newTaskIndex - 1] ?? null
const taskAfter = newBucket.tasks[newTaskIndex + 1] ?? null
taskUpdating.value[task.id] = true
const newTask = klona(task) // cloning the task to avoid pinia store manipulation
newTask.bucketId = newBucket.id
const position = calculateItemPosition(
taskBefore !== null ? taskBefore.kanbanPosition : null,
taskAfter !== null ? taskAfter.kanbanPosition : null,
)
if (
oldBucket !== undefined && // This shouldn't actually be `undefined`, but let's play it safe.
newBucket.id !== oldBucket.id
) {
newTask.done = project.value?.doneBucketId === newBucket.id
}
let bucketHasChanged = false
if (
oldBucket !== undefined && // This shouldn't actually be `undefined`, but let's play it safe.
newBucket.id !== oldBucket.id
) {
kanbanStore.setBucketById({
...oldBucket,
count: oldBucket.count - 1,
})
kanbanStore.setBucketById({
...newBucket,
count: newBucket.count + 1,
})
bucketHasChanged = true
}
try {
const newPosition = new TaskPositionModel({
position,
projectViewId: viewId,
taskId: newTask.id,
})
await taskPositionService.value.update(newPosition)
if(bucketHasChanged) {
await taskStore.update(newTask)
}
// Make sure the first and second task don't both get position 0 assigned
if (newTaskIndex === 0 && taskAfter !== null && taskAfter.kanbanPosition === 0) {
const taskAfterAfter = newBucket.tasks[newTaskIndex + 2] ?? null
const newTaskAfter = klona(taskAfter) // cloning the task to avoid pinia store manipulation
newTaskAfter.bucketId = newBucket.id
newTaskAfter.kanbanPosition = calculateItemPosition(
0,
taskAfterAfter !== null ? taskAfterAfter.kanbanPosition : null,
)
await taskStore.update(newTaskAfter)
}
} finally {
taskUpdating.value[task.id] = false
oneTaskUpdating.value = false
}
}
function toggleShowNewTaskInput(bucketId: IBucket['id']) {
showNewTaskInput.value[bucketId] = !showNewTaskInput.value[bucketId]
newTaskInputFocused.value = false
}
async function addTaskToBucket(bucketId: IBucket['id']) {
if (newTaskText.value === '') {
newTaskError.value[bucketId] = true
return
}
newTaskError.value[bucketId] = false
const task = await taskStore.createNewTask({
title: newTaskText.value,
bucketId,
projectId: project.value.id,
})
newTaskText.value = ''
kanbanStore.addTaskToBucket(task)
scrollTaskContainerToBottom(bucketId)
}
function scrollTaskContainerToBottom(bucketId: IBucket['id']) {
const bucketEl = taskContainerRefs.value[bucketId]
if (!bucketEl) {
return
}
bucketEl.scrollTop = bucketEl.scrollHeight
}
async function createNewBucket() {
if (newBucketTitle.value === '') {
return
}
await kanbanStore.createBucket(new BucketModel({
title: newBucketTitle.value,
projectId: project.value.id,
projectViewId: viewId,
}))
newBucketTitle.value = ''
}
function deleteBucketModal(bucketId: IBucket['id']) {
if (buckets.value.length <= 1) {
return
}
bucketToDelete.value = bucketId
showBucketDeleteModal.value = true
}
async function deleteBucket() {
try {
await kanbanStore.deleteBucket({
bucket: new BucketModel({
id: bucketToDelete.value,
projectId: project.value.id,
projectViewId: viewId,
}),
params: params.value,
})
success({message: t('project.kanban.deleteBucketSuccess')})
} finally {
showBucketDeleteModal.value = false
}
}
/** This little helper allows us to drag a bucket around at the title without focusing on it right away. */
async function focusBucketTitle(e: Event) {
bucketTitleEditable.value = true
await nextTick()
const target = e.target as HTMLInputElement
target.focus()
}
async function saveBucketTitle(bucketId: IBucket['id'], bucketTitle: string) {
const bucket = kanbanStore.getBucketById(bucketId)
if (bucket?.title === bucketTitle) {
bucketTitleEditable.value = false
return
}
await kanbanStore.updateBucket({
id: bucketId,
title: bucketTitle,
projectId,
})
success({message: i18n.global.t('project.kanban.bucketTitleSavedSuccess')})
bucketTitleEditable.value = false
}
function updateBuckets(value: IBucket[]) {
// (1) buckets get updated in store and tasks positions get invalidated
kanbanStore.setBuckets(value)
}
// TODO: fix type
function updateBucketPosition(e: { newIndex: number }) {
// (2) bucket positon is changed
dragBucket.value = false
const bucket = buckets.value[e.newIndex]
const bucketBefore = buckets.value[e.newIndex - 1] ?? null
const bucketAfter = buckets.value[e.newIndex + 1] ?? null
kanbanStore.updateBucket({
id: bucket.id,
projectId,
position: calculateItemPosition(
bucketBefore !== null ? bucketBefore.position : null,
bucketAfter !== null ? bucketAfter.position : null,
),
})
}
async function saveBucketLimit(bucketId: IBucket['id'], limit: number) {
if (limit < 0) {
return
}
await kanbanStore.updateBucket({
...kanbanStore.getBucketById(bucketId),
projectId,
limit,
})
success({message: t('project.kanban.bucketLimitSavedSuccess')})
}
const setBucketLimitCancel = ref<number | null>(null)
async function setBucketLimit(bucketId: IBucket['id'], now: boolean = false) {
const limit = parseInt(bucketLimitInputRef.value?.value || '')
if (setBucketLimitCancel.value !== null) {
clearTimeout(setBucketLimitCancel.value)
}
if (now) {
return saveBucketLimit(bucketId, limit)
}
setBucketLimitCancel.value = setTimeout(saveBucketLimit, 2500, bucketId, limit)
}
function shouldAcceptDrop(bucket: IBucket) {
return (
// When dragging from a bucket who has its limit reached, dragging should still be possible
bucket.id === sourceBucket.value ||
// If there is no limit set, dragging & dropping should always work
bucket.limit === 0 ||
// Disallow dropping to buckets which have their limit reached
bucket.count < bucket.limit
)
}
function dragstart(bucket: IBucket) {
drag.value = true
sourceBucket.value = bucket.id
}
async function toggleDefaultBucket(bucket: IBucket) {
const defaultBucketId = view.value?.defaultBucketId === bucket.id
? 0
: bucket.id
const projectViewService = new ProjectViewService()
const updatedView = await projectViewService.update(new ProjectViewModel({
...view.value,
defaultBucketId,
}))
const views = project.value.views.map(v => v.id === view.value?.id ? updatedView : v)
const updatedProject = {
...project.value,
views,
}
projectStore.setProject(updatedProject)
success({message: t('project.kanban.defaultBucketSavedSuccess')})
}
async function toggleDoneBucket(bucket: IBucket) {
const doneBucketId = view.value?.doneBucketId === bucket.id
? 0
: bucket.id
const projectViewService = new ProjectViewService()
const updatedView = await projectViewService.update(new ProjectViewModel({
...view.value,
doneBucketId,
}))
const views = project.value.views.map(v => v.id === view.value?.id ? updatedView : v)
const updatedProject = {
...project.value,
views,
}
projectStore.setProject(updatedProject)
success({message: t('project.kanban.doneBucketSavedSuccess')})
}
function collapseBucket(bucket: IBucket) {
collapsedBuckets.value[bucket.id] = true
saveCollapsedBucketState(project.value.id, collapsedBuckets.value)
}
function unCollapseBucket(bucket: IBucket) {
if (!collapsedBuckets.value[bucket.id]) {
return
}
collapsedBuckets.value[bucket.id] = false
saveCollapsedBucketState(project.value.id, collapsedBuckets.value)
}
</script>
<style lang="scss">
$ease-out: all .3s cubic-bezier(0.23, 1, 0.32, 1);
$bucket-width: 300px;
$bucket-header-height: 60px;
$bucket-right-margin: 1rem;
$crazy-height-calculation: '100vh - 4.5rem - 1.5rem - 1rem - 1.5rem - 11px';
$crazy-height-calculation-tasks: '#{$crazy-height-calculation} - 1rem - 2.5rem - 2rem - #{$button-height} - 1rem';
$filter-container-height: '1rem - #{$switch-view-height}';
// FIXME:
.app-content.project\.kanban, .app-content.task\.detail {
padding-bottom: 0 !important;
}
.kanban {
overflow-x: auto;
overflow-y: hidden;
height: calc(#{$crazy-height-calculation});
margin: 0 -1.5rem;
padding: 0 1.5rem;
@media screen and (max-width: $tablet) {
height: calc(#{$crazy-height-calculation} - #{$filter-container-height});
scroll-snap-type: x mandatory;
}
&-bucket-container {
display: flex;
}
.ghost {
position: relative;
* {
opacity: 0;
}
&::after {
content: '';
position: absolute;
display: block;
top: 0.25rem;
right: 0.5rem;
bottom: 0.25rem;
left: 0.5rem;
border: 3px dashed var(--grey-300);
border-radius: $radius;
}
}
.bucket {
border-radius: $radius;
position: relative;
margin: 0 $bucket-right-margin 0 0;
max-height: calc(100% - 1rem); // 1rem spacing to the bottom
min-height: 20px;
width: $bucket-width;
display: flex;
flex-direction: column;
overflow: hidden; // Make sure the edges are always rounded
@media screen and (max-width: $tablet) {
scroll-snap-align: center;
}
.tasks {
overflow: hidden auto;
height: 100%;
}
.task-item {
background-color: var(--grey-100);
padding: .25rem .5rem;
&:first-of-type {
padding-top: .5rem;
}
&:last-of-type {
padding-bottom: .5rem;
}
}
.no-move {
transition: transform 0s;
}
h2 {
font-size: 1rem;
margin: 0;
font-weight: 600 !important;
}
&.new-bucket {
// Because of reasons, this button ignores the margin we gave it to the right.
// To make it still look like it has some, we modify the container to have a padding of 1rem,
// which is the same as the margin it should have. Then we make the container itself bigger
// to hide the fact we just made the button smaller.
min-width: calc(#{$bucket-width} + 1rem);
background: transparent;
padding-right: 1rem;
.button {
background: var(--grey-100);
width: 100%;
}
}
&.is-collapsed {
align-self: flex-start;
transform: rotate(90deg) translateY(-100%);
transform-origin: top left;
// Using negative margins instead of translateY here to make all other buckets fill the empty space
margin-right: calc((#{$bucket-width} - #{$bucket-header-height} - #{$bucket-right-margin}) * -1);
cursor: pointer;
.tasks, .bucket-footer {
display: none;
}
}
}
.bucket-header {
background-color: var(--grey-100);
height: min-content;
display: flex;
align-items: center;
justify-content: space-between;
padding: .5rem;
height: $bucket-header-height;
.limit {
padding: 0 .5rem;
font-weight: bold;
&.is-max {
color: var(--danger);
}
}
.title.input {
height: auto;
padding: .4rem .5rem;
display: inline-block;
cursor: pointer;
}
}
:deep(.dropdown-trigger) {
padding: .5rem;
}
.bucket-footer {
position: sticky;
bottom: 0;
height: min-content;
padding: .5rem;
background-color: var(--grey-100);
border-bottom-left-radius: $radius;
border-bottom-right-radius: $radius;
transform: none;
.button {
background-color: transparent;
&:hover {
background-color: var(--white);
}
}
}
}
// FIXME: This does not seem to work
.task-dragging {
transform: rotateZ(3deg);
transition: transform 0.18s ease;
}
.move-card-move {
transform: rotateZ(3deg);
transition: transform $transition-duration;
}
.move-card-leave-from,
.move-card-leave-to,
.move-card-leave-active {
display: none;
}
</style>