Newer
Older
gnexus-tasks / frontend / src / components / TaskCard.vue
<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
    .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>