<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import type { Task } from '../api'
import { formatDate, proposalItems, statusOptions, timeAgo } from '../taskui'
import TaskBadges from './TaskBadges.vue'
// Карточка задачи для списков (список, страница проекта): заголовок-ссылка
// на страницу задачи, сниппет описания/предложения ИИ, бейджи, «⋯»-меню.
// С 0.75 кликабельна целиком (см. onCardClick).
// API-вызовы и подтверждение удаления — на родителе (эмитим события).
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]
}>()
const { t } = useI18n()
const router = useRouter()
// Сниппет: описание задачи, а без него — пункты предложения ИИ.
// Markdown-разметку убираем — сниппет plain-текст.
const snippet = computed(() => {
const text =
props.task.description.trim() || proposalItems(props.task).map((i) => i.label).join(' · ')
return text
.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),
})),
{ 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 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) : '',
)
</script>
<template>
<GnCard class="task-card" @click="onCardClick">
<div class="task-main">
<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>
</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);
}
.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>