164 lines
5.7 KiB
TypeScript
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>
|
|
);
|
|
}
|