<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import type { Task } from '../api'
import { formatDate, proposalItems, recurrenceLabel, statusOptions, timeAgo } from '../taskui'
import TaskBadges from './TaskBadges.vue'
import TaskMoveDialog from './TaskMoveDialog.vue'
// Карточка задачи для списков (список, страница проекта): заголовок-ссылка
// на страницу задачи, сниппет описания/предложения ИИ, бейджи, «⋯»-меню.
// С 0.75 кликабельна целиком (см. onCardClick).
// API-вызовы и подтверждение удаления — на родителе (эмитим события).
// Исключение — перенос в другой проект (0.82): он вызревает из меню карточки и
// нужен всем спискам сразу, поэтому вызов живёт в TaskMoveDialog, а карточка
// только просит родителя перечитать список (см. TaskMoveDialog).
const props = defineProps<{
task: Task
/** Показывать бейдж проекта (скрывать на странице проекта) */
showProject?: boolean
/** Параметры запроса у ссылок на задачу (например { via: 'options' }) */
linkQuery?: Record<string, string>
}>()
const emit = defineEmits<{
'status-change': [task: Task, status: string]
delete: [task: Task]
/** Задачу перенесли в другой проект — списку пора перечитать данные */
moved: []
}>()
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
// Диалог переноса — своим состоянием карточки: он один на всю карточку,
// а открывают его из меню
const moveOpen = ref(false)
// Сниппет: описание задачи, а без него — пункты предложения ИИ.
// Markdown-разметку убираем — сниппет plain-текст.
const snippet = computed(() => {
const text =
props.task.description.trim() || proposalItems(props.task).map((i) => i.label).join(' · ')
return text
// Картинки — до ссылок: у `` правило ссылок съедает `[alt](url)`,
// но ведущий `!` остаётся и лезет в сниппет («!screenshot»). ТЗ 3.22 наполняет
// описания скриншотами из багрепортов, так что вырезаем их целиком первыми
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '')
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
.replace(/\*\*([^*]+)\*\*/g, '$1')
.replace(/\*([^*]+)\*/g, '$1')
.replace(/`([^`]+)`/g, '$1')
})
function menu(task: Task) {
return [
...statusOptions().map((o) => ({
label: o.label,
icon: o.value === task.status ? 'ph-check' : undefined,
onSelect: () => emit('status-change', task, o.value),
})),
// Проект задачи (0.82): «перейти» и «переместить» стоят рядом — это один вопрос
// «в каком проекте задача». «Перейти» у задачи без проекта не показываем (вести
// некуда), как и на странице самого проекта: там задача и так лежит в нём
...(task.project && route.path !== `/projects/${task.project.id}`
? [
{
label: t('task.goToProject'),
icon: 'ph-folder',
onSelect: () => gotoProject(task),
},
]
: []),
{
// «В другой проект» (0.83) вместо «Переместить в проект»: в китовом меню
// (220px) длинный пункт рвался на две строки
label: t('task.moveToOther'),
icon: 'ph-arrows-left-right',
onSelect: () => {
moveOpen.value = true
},
},
{ label: t('common.open'), icon: 'ph-arrow-square-out', onSelect: () => open(task.id) },
{ label: t('common.delete'), icon: 'ph-trash', danger: true, onSelect: () => emit('delete', task) },
]
}
function gotoProject(task: Task) {
if (task.project) void router.push(`/projects/${task.project.id}`)
}
function open(id: number) {
void router.push({ path: `/tasks/${id}`, query: props.linkQuery })
}
// Карточка кликабельна целиком (0.75). Внутри есть свои интерактивные элементы —
// заголовок-ссылка, бейдж проекта, «⋯»-меню: клик по ним это их работа (иначе
// получалась бы двойная навигация), а карточка ловит клики мимо них. Выделенный
// текст не считаем кликом: иначе не даёт скопировать сниппет
function onCardClick(event: MouseEvent) {
const el = event.target as HTMLElement | null
if (el?.closest('a, button, input, select, textarea, label')) return
if (window.getSelection()?.toString()) return
open(props.task.id)
}
// Когда задача добавлена (0.75): «3 дня назад» — как свежесть в карточке проекта
// (0.69), полная дата — в подсказке. Пустой created_at даёт пустую строку
const createdAgo = computed(() => timeAgo(Date.parse(props.task.created_at)))
const createdTitle = computed(() =>
props.task.created_at ? formatDate(props.task.created_at) : '',
)
// Метка регулярной задачи (0.91, ТЗ 3.5). Раньше регулярную в списке было не
// отличить от разовой: правило жило только в бейдже среди прочих бейджей, и
// владелец, закрыв ежедневную задачу, видел её же снова «к выполнению» без даты.
// Теперь два независимых канала — «видно, не читая»: кружок с иконкой повтора
// перед названием и акцентная полоса по левой кромке карточки.
const isRecurring = computed(() => props.task.task_type === 'recurring')
// Подсказка метки — словами о правиле и о следующем экземпляре: иконка отвечает
// «что это», подсказка — «как это работает»
const recurHint = computed(() => {
const rule = recurrenceLabel(props.task) || t('taskui.recur.recurring')
return props.task.deadline_date
? t('taskui.recur.hintNext', { rule, date: formatDate(props.task.deadline_date) })
: t('taskui.recur.hint', { rule })
})
</script>
<template>
<GnCard class="task-card" :class="{ recurring: isRecurring }" @click="onCardClick">
<div class="task-main">
<!-- Метка регулярной (0.91): стоит перед названием — по ней карточка
опознаётся до чтения заголовка. Иконка смысловая, поэтому role="img"
с подписью, а не aria-hidden, как у декоративных глифов в бейджах -->
<span
v-if="isRecurring"
class="recur-mark"
role="img"
:aria-label="recurHint"
:title="recurHint"
>
<i class="ph ph-repeat" aria-hidden="true" />
</span>
<RouterLink
class="title"
:class="{ done: task.status === 'done' }"
:to="linkQuery ? { path: `/tasks/${task.id}`, query: linkQuery } : `/tasks/${task.id}`"
>
{{ task.title }}
</RouterLink>
<GnDropdown class="task-menu" :items="menu(task)">
<template #trigger="{ toggle }">
<GnIconButton icon="ph-dots-three-outline" :label="t('common.edit')" @click="toggle" />
</template>
</GnDropdown>
</div>
<p v-if="snippet" class="snippet">{{ snippet }}</p>
<div class="task-meta">
<TaskBadges :task="task" :hide-project="!showProject" />
</div>
<!-- Подвал карточки (0.76): свежесть задачи — контекст карточки, а не её
метаданные, ей место в подвале. Полная дата — в подсказке -->
<template #footer>
<span v-if="createdAgo" class="card-age" :title="createdTitle">
<i class="ph ph-clock-counter-clockwise normalize" aria-hidden="true" />
{{ createdAgo }}
</span>
</template>
</GnCard>
<!-- Диалог переноса (0.82). Сосед карточки, а не её содержимое: кит уводит
модалку телепортом в body, поэтому в разметке карточки от неё ничего не
остаётся, и клик по диалогу не считается кликом по карточке -->
<TaskMoveDialog v-model:open="moveOpen" :task="task" @moved="emit('moved')" />
</template>
<style scoped>
/* Вся карточка — вход на страницу задачи (0.75): курсор и подсветка рамки, как
у карточки проекта. Клик мимо внутренних элементов ловит onCardClick */
.task-card {
cursor: pointer;
/* колонкой — чтобы подвал лёг вниз карточки (0.76) */
display: flex;
flex-direction: column;
}
/* Кит тянет содержимое карточки на height:100%: с подвалом оно вытолкнуло бы его
за карточку (в сетке списка у карточки height:100%, а .app-content .card снимает
overflow кита). Отдаём остаток высоты содержимому — подвал прижат к низу */
.task-card :deep(.card-content) {
flex: 1;
height: auto;
}
.task-card:hover {
border-color: var(--accent, #7aa2f7);
}
/* Регулярная задача (0.91, ТЗ 3.5): акцентная полоса по левой кромке. inset-тень,
а не border-left — она не сдвигает содержимое и не спорит с рамкой и радиусом
кита: у карточки с задачей и без неё одинаковая раскладка. Толщина 4px, а не
3px: под полупрозрачной китовой рамкой 2px полоса читалась на грани заметного */
.task-card.recurring {
box-shadow: inset 4px 0 0 var(--accent, #7aa2f7);
}
/* Кружок с иконкой повтора перед названием: акцентная подложка, чтобы метка
читалась как знак, а не как ещё один бейдж в ряду. flex: none — иначе
сжимался бы вместе с длинным названием */
.recur-mark {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.7rem;
height: 1.7rem;
border-radius: 50%;
background: color-mix(in srgb, var(--accent, #7aa2f7) 18%, transparent);
color: var(--accent, #7aa2f7);
font-size: 1.05rem;
line-height: 1;
cursor: help;
}
.task-main {
display: flex;
gap: 0.5rem;
align-items: center;
}
.title {
flex: 1;
min-width: 0;
font-weight: 600;
color: inherit;
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.title:hover {
color: var(--accent, #7aa2f7);
}
.title.done {
text-decoration: line-through;
color: var(--text-muted, #888);
}
.snippet {
margin: 0.25rem 0 0;
color: var(--text-muted, #a9b1d6);
font-size: 0.9em;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.task-menu {
flex: none;
}
.task-meta {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
align-items: center;
margin-top: 0.5rem;
}
/* Сам подвал (черта, раскладка, мягкий текст) — общая полоса карточек задач и
проектов, она в kit-overrides.css (0.81): правила были побайтово одни и те же
в двух местах, а отступы кита, наоборот, разъезжались (8px сверху против 15px
снизу) — строка подвала висела выше середины полосы */
.card-age {
display: inline-flex;
align-items: center;
gap: 0.25rem;
white-space: nowrap;
}
/* Иконка рядом с текстом идёт с китовым классом `normalize` (`.ph.normalize{top:.15em}`):
глиф иконочного шрифта садится на 1.7px выше середины текста и без поправки читается
подвешенным. Замер после поправки: 0.3px от середины прописных (цифра «2»), 0.8px от
середины строчных («з») — то есть ровно между ними, как и надо иконке в строке */
</style>