Corp/frontend/src/components/tasks/TaskLinkPill.tsx
2026-08-19 17:01:43 +03:00

164 lines
5.7 KiB
TypeScript

import Link from "next/link";
import { createPortal } from "react-dom";
import type { CSSProperties } from "react";
import { useState } from "react";
import TaskReadOnlyPreviewModal from "@/components/tasks/TaskReadOnlyPreviewModal";
import type { MessageLinkedTask, TaskPriority } from "@/types/api";
type TaskLinkPillTask = Pick<
MessageLinkedTask,
| "id"
| "title"
| "board_id"
| "board_name"
| "column_color"
| "priority"
| "priority_display"
| "can_open"
> & { link_id?: number };
type TaskLinkPillProps = {
task: TaskLinkPillTask;
tone?: "default" | "onAccent";
maxTitleClassName?: string;
className?: string;
};
const fallbackColumnColor = "#38bdf8";
const priorityColors: Record<TaskPriority, string> = {
low: "#22c55e",
medium: "#38bdf8",
high: "#f59e0b",
critical: "#ef4444",
};
const priorityLabels: Record<TaskPriority, string> = {
low: "Низкий",
medium: "Средний",
high: "Высокий",
critical: "Критический",
};
const priorityTitleShiftClasses: Record<TaskPriority, string> = {
low: "group-hover/task-pill:translate-x-10 group-focus-visible/task-pill:translate-x-10",
medium: "group-hover/task-pill:translate-x-12 group-focus-visible/task-pill:translate-x-12",
high: "group-hover/task-pill:translate-x-12 group-focus-visible/task-pill:translate-x-12",
critical: "group-hover/task-pill:translate-x-16 group-focus-visible/task-pill:translate-x-16",
};
function normalizeHexColor(value?: string | null): string {
const color = String(value || "").trim();
if (/^#[0-9a-f]{6}$/i.test(color)) return color;
if (/^#[0-9a-f]{3}$/i.test(color)) {
const [, r, g, b] = color;
return `#${r}${r}${g}${g}${b}${b}`;
}
return fallbackColumnColor;
}
function hexToRgb(color: string): { red: number; green: number; blue: number } {
const normalized = normalizeHexColor(color).slice(1);
return {
red: parseInt(normalized.slice(0, 2), 16),
green: parseInt(normalized.slice(2, 4), 16),
blue: parseInt(normalized.slice(4, 6), 16),
};
}
function getReadableTextColor(color: string): string {
const { red, green, blue } = hexToRgb(color);
const luminance = (0.299 * red + 0.587 * green + 0.114 * blue) / 255;
return luminance > 0.62 ? "#07111f" : "#ffffff";
}
export default function TaskLinkPill({
task,
tone = "default",
maxTitleClassName = "max-w-44",
className = "",
}: TaskLinkPillProps) {
const [previewOpen, setPreviewOpen] = useState(false);
const columnColor = normalizeHexColor(task.column_color);
const priority = task.priority || "medium";
const priorityColor = priorityColors[priority];
const priorityLabel = task.priority_display || priorityLabels[priority];
const priorityTitleShiftClass = priorityTitleShiftClasses[priority];
const textColor = getReadableTextColor(columnColor);
const isOnAccent = tone === "onAccent";
const title = `${task.board_name ? `${task.board_name}: ` : ""}${task.title}, ${priorityLabel}`;
const style: CSSProperties = {
backgroundColor: columnColor,
boxShadow: isOnAccent ? "inset 0 1px 0 rgba(255, 255, 255, 0.18)" : undefined,
color: textColor,
};
const href = `/tasks?board=${task.board_id}&task=${task.id}`;
const canOpen = task.can_open !== false;
const pillClassName = `group/task-pill inline-flex min-w-0 max-w-full items-center gap-1.5 overflow-hidden rounded-full px-2 py-1 text-[11px] font-medium shadow-sm transition ${
canOpen
? "hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/45"
: "cursor-pointer hover:brightness-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/45"
} ${className}`;
const content = (
<>
<span className="relative h-4 w-2.5 shrink-0" aria-hidden="true">
<span
className="absolute left-0 top-1/2 z-10 inline-flex h-2.5 min-w-2.5 max-w-2.5 -translate-y-1/2 items-center justify-center overflow-hidden rounded-full border transition-[height,max-width,padding] duration-200 ease-out group-hover/task-pill:h-4 group-hover/task-pill:max-w-24 group-hover/task-pill:px-1.5 group-focus-visible/task-pill:h-4 group-focus-visible/task-pill:max-w-24 group-focus-visible/task-pill:px-1.5"
style={{
backgroundColor: priorityColor,
borderColor: textColor,
}}
>
<span className="max-w-0 whitespace-nowrap text-[9px] font-semibold leading-none text-white opacity-0 transition-[max-width,opacity] duration-200 ease-out group-hover/task-pill:max-w-20 group-hover/task-pill:opacity-100 group-focus-visible/task-pill:max-w-20 group-focus-visible/task-pill:opacity-100">
{priorityLabel}
</span>
</span>
</span>
<span className={`${maxTitleClassName} min-w-0 truncate transition-transform duration-200 ease-out ${priorityTitleShiftClass}`}>
{task.title}
</span>
</>
);
if (!canOpen) {
return (
<>
<button
type="button"
className={pillClassName}
style={style}
title={`${title}. Открыть только для чтения`}
aria-haspopup="dialog"
onClick={() => setPreviewOpen(true)}
disabled={!task.link_id}
>
{content}
</button>
{task.link_id && previewOpen && typeof document !== "undefined"
? createPortal(
<TaskReadOnlyPreviewModal
isOpen={previewOpen}
linkId={task.link_id}
taskId={task.id}
onClose={() => setPreviewOpen(false)}
/>,
document.body,
)
: null}
</>
);
}
return (
<Link
href={href}
className={pillClassName}
style={style}
title={title}
>
{content}
</Link>
);
}