improve task board controls and media previews

This commit is contained in:
lizerk 2026-08-07 15:32:41 +03:00
parent 2fec931bda
commit fad399cd47
3 changed files with 287 additions and 17 deletions

View File

@ -1,7 +1,9 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { createPortal } from "react-dom";
import {
DndContext,
DragOverlay,
@ -44,6 +46,7 @@ import {
ListChecks,
Loader2,
MessageSquare,
Maximize2,
Newspaper,
Paperclip,
Pencil,
@ -79,6 +82,8 @@ import TaskBoardNavigator from "@/components/tasks/TaskBoardNavigator";
import TaskCoverPreview from "@/components/tasks/TaskCoverPreview";
import TaskCommentAttachment from "@/components/tasks/TaskCommentAttachment";
import TaskAutomationManager from "@/components/tasks/TaskAutomationManager";
import ChatMediaPreviewModal from "@/components/messages/ChatMediaPreviewModal";
import type { MediaPreview } from "@/components/messages/ChatMessageItem";
import { RequestAvatar } from "@/components/requests/RequestAvatar";
import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls";
import { Modal } from "@/components/ui";
@ -319,6 +324,7 @@ const priorityOptions: {
className: string;
textClassName: string;
dotClassName: string;
selectedClassName: string;
}[] = [
{
value: "low",
@ -327,6 +333,7 @@ const priorityOptions: {
className: "app-badge",
textClassName: "app-text-muted",
dotClassName: "bg-slate-400",
selectedClassName: "bg-slate-500",
},
{
value: "medium",
@ -335,6 +342,7 @@ const priorityOptions: {
className: "app-selected",
textClassName: "text-[var(--accent-primary-strong)]",
dotClassName: "bg-sky-500",
selectedClassName: "bg-sky-500",
},
{
value: "high",
@ -343,6 +351,7 @@ const priorityOptions: {
className: "app-feedback-warning",
textClassName: "text-[var(--warning-foreground)]",
dotClassName: "bg-amber-500",
selectedClassName: "bg-amber-500",
},
{
value: "critical",
@ -351,6 +360,7 @@ const priorityOptions: {
className: "app-feedback-danger",
textClassName: "text-[var(--danger-foreground)]",
dotClassName: "bg-red-500",
selectedClassName: "bg-red-500",
},
];
@ -445,6 +455,116 @@ function isMediaAttachment(attachment: TaskAttachment): boolean {
return isImageAttachment(attachment) || isVideoAttachment(attachment);
}
function TaskAttachmentThumbnail({
attachment,
onPreview,
}: {
attachment: TaskAttachment;
onPreview: (preview: MediaPreview) => void;
}) {
const [source, setSource] = useState<string | null>(null);
const [failed, setFailed] = useState(false);
const image = isImageAttachment(attachment);
const video = isVideoAttachment(attachment);
const attachmentId = attachment.id;
const taskId = attachment.task;
const downloadUrl = attachment.download_url;
const fileName = attachment.file_name;
useEffect(() => {
if (!image && !video) return;
let active = true;
let objectUrl: string | null = null;
void apiClient.downloadTaskAttachment(taskId, {
id: attachmentId,
download_url: downloadUrl,
file_name: fileName,
})
.then(({ blob }) => {
if (!active) return;
objectUrl = URL.createObjectURL(blob);
setSource(objectUrl);
})
.catch(() => {
if (active) setFailed(true);
});
return () => {
active = false;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [attachmentId, downloadUrl, fileName, image, taskId, video]);
if (!image && !video) {
return (
<span className="app-selected flex h-9 w-11 shrink-0 items-center justify-center rounded-lg">
<FileText size={15} />
</span>
);
}
return (
<button
type="button"
disabled={!source || failed}
onClick={() => {
if (!source || failed) return;
onPreview({
type: video ? "video" : "image",
src: source,
name: fileName,
});
}}
className="group relative flex h-9 w-11 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-[var(--surface-secondary)] disabled:cursor-default"
title={source && !failed ? `Просмотреть ${fileName}` : undefined}
aria-label={`Предпросмотр ${fileName}`}
>
{failed ? (
<FileText size={15} className="app-text-muted" />
) : source && image ? (
<Image
src={source}
alt=""
fill
unoptimized
sizes="44px"
className="object-cover"
onError={() => setFailed(true)}
/>
) : source && video ? (
<>
<video
src={source}
muted
playsInline
preload="metadata"
className="pointer-events-none h-full w-full object-cover"
onError={() => setFailed(true)}
onLoadedMetadata={(event) => {
const media = event.currentTarget;
if (Number.isFinite(media.duration) && media.duration > 0.2) {
media.currentTime = Math.min(0.25, media.duration / 10);
}
}}
/>
<span className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/20 text-white transition-opacity group-hover:opacity-0">
<Play size={13} className="translate-x-px drop-shadow" fill="currentColor" />
</span>
</>
) : (
<Loader2 size={14} className="app-text-muted animate-spin" />
)}
{source && !failed ? (
<span className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/45 text-white opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100">
<Eye size={15} />
</span>
) : null}
</button>
);
}
function isImageFile(file: File): boolean {
if (file.type.toLowerCase().startsWith("image/")) return true;
return /\.(avif|bmp|gif|heic|heif|jpe?g|png|webp)$/i.test(file.name);
@ -837,16 +957,26 @@ function QuickTaskComposer({
type="button"
onClick={() => onPriorityChange(option.value)}
disabled={saving}
className={`flex h-6 w-6 items-center justify-center rounded-full transition disabled:opacity-50 ${
className={`flex h-6 min-w-6 items-center overflow-hidden rounded-full transition-all duration-200 ease-out disabled:opacity-50 ${
selected
? "bg-[var(--surface-tertiary)] ring-2 ring-[var(--accent-primary)]"
: "hover:bg-[var(--surface-tertiary)]"
? `max-w-28 px-2 shadow-sm ${option.selectedClassName}`
: "w-6 max-w-6 justify-center px-0 hover:bg-[var(--surface-tertiary)]"
}`}
title={`Приоритет: ${option.label}`}
aria-label={`Приоритет: ${option.label}`}
aria-pressed={selected}
>
<span className={`h-3 w-3 rounded-full ${option.dotClassName}`} aria-hidden="true" />
{!selected ? (
<span
className={`h-3 w-3 shrink-0 rounded-full ${option.dotClassName}`}
aria-hidden="true"
/>
) : null}
{selected ? (
<span className="whitespace-nowrap text-[10px] font-semibold leading-none text-white">
{option.label}
</span>
) : null}
</button>
);
})}
@ -877,6 +1007,90 @@ function QuickTaskComposer({
);
}
function TaskAddMenuPopover({
anchorRef,
menuRef,
placement,
children,
}: {
anchorRef: RefObject<HTMLDivElement | null>;
menuRef: RefObject<HTMLDivElement | null>;
placement: "top" | "bottom";
children: ReactNode;
}) {
const [position, setPosition] = useState<{
left: number;
top?: number;
bottom?: number;
maxHeight: number;
} | null>(null);
useEffect(() => {
const updatePosition = () => {
const anchor = anchorRef.current;
if (!anchor) return;
const rect = anchor.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const menuWidth = 240;
const viewportGap = 8;
const menuGap = 8;
const preferredLeft = placement === "bottom"
? rect.right - menuWidth
: rect.left;
const left = Math.min(
Math.max(viewportGap, preferredLeft),
Math.max(viewportGap, viewportWidth - menuWidth - viewportGap),
);
if (placement === "bottom") {
setPosition({
left,
bottom: viewportHeight - rect.top + menuGap,
maxHeight: Math.max(96, rect.top - viewportGap - menuGap),
});
return;
}
setPosition({
left,
top: rect.bottom + menuGap,
maxHeight: Math.max(
96,
viewportHeight - rect.bottom - viewportGap - menuGap,
),
});
};
updatePosition();
window.addEventListener("resize", updatePosition);
window.addEventListener("scroll", updatePosition, true);
return () => {
window.removeEventListener("resize", updatePosition);
window.removeEventListener("scroll", updatePosition, true);
};
}, [anchorRef, placement]);
if (!position || typeof document === "undefined") return null;
return createPortal(
<div
ref={menuRef}
className="app-menu fixed z-[220] w-60 overflow-y-auto overscroll-contain rounded-xl p-1.5 shadow-xl"
style={{
left: position.left,
top: position.top,
bottom: position.bottom,
maxHeight: position.maxHeight,
}}
>
{children}
</div>,
document.body,
);
}
type TaskColumnCardsProps = {
tasks: TaskCard[];
onOpenTask: (task: TaskCard) => void;
@ -3411,6 +3625,7 @@ function TasksPageContent() {
const [taskAttachments, setTaskAttachments] = useState<TaskAttachment[]>([]);
const [taskAttachmentsLoading, setTaskAttachmentsLoading] = useState(false);
const [taskAttachmentsUploading, setTaskAttachmentsUploading] = useState(false);
const [taskMediaPreview, setTaskMediaPreview] = useState<MediaPreview | null>(null);
const [checklistOpen, setChecklistOpen] = useState(false);
const [taskChecklist, setTaskChecklist] = useState<TaskChecklistItem[]>([]);
const [taskChecklistLoaded, setTaskChecklistLoaded] = useState(false);
@ -3428,6 +3643,7 @@ function TasksPageContent() {
const taskMenuRef = useRef<HTMLDivElement | null>(null);
const taskAddMenuRef = useRef<HTMLDivElement | null>(null);
const taskAddMenuBottomRef = useRef<HTMLDivElement | null>(null);
const taskAddMenuPopoverRef = useRef<HTMLDivElement | null>(null);
const viewColumnMenuRef = useRef<HTMLDivElement | null>(null);
const cardMenuRef = useRef<HTMLDivElement | null>(null);
const columnMenuRef = useRef<HTMLDivElement | null>(null);
@ -4036,6 +4252,7 @@ function TasksPageContent() {
if (
activeMenuRef.current
&& !activeMenuRef.current.contains(event.target as Node)
&& !taskAddMenuPopoverRef.current?.contains(event.target as Node)
) {
setTaskAddMenuOpen(false);
}
@ -4555,6 +4772,7 @@ function TasksPageContent() {
setEditingCommentId(null);
setEditingCommentDraft("");
setTaskAttachments([]);
setTaskMediaPreview(null);
setTaskChecklist([]);
setTaskChecklistLoaded(false);
setTaskChecklistDraft("");
@ -4599,6 +4817,7 @@ function TasksPageContent() {
setEditingCommentId(null);
setEditingCommentDraft("");
setTaskAttachments([]);
setTaskMediaPreview(null);
setTaskChecklist([]);
setTaskChecklistLoaded(false);
setTaskActivities([]);
@ -6831,6 +7050,18 @@ function TasksPageContent() {
</div>
</div>
<div className="flex flex-wrap gap-2">
{!desktopWideMode ? (
<button
type="button"
onClick={() => changeDesktopWideMode(true)}
className="app-action-ghost hidden h-8 w-8 items-center justify-center rounded-md lg:flex"
title="Развернуть доску"
aria-label="Развернуть доску"
aria-pressed="false"
>
<Maximize2 size={16} />
</button>
) : null}
<button
type="button"
onClick={() => openCreateTask()}
@ -7715,11 +7946,8 @@ function TasksPageContent() {
onClose={closeTaskView}
title="Задача"
size="md"
className={taskAddMenuOpen
? "min-h-[min(34rem,95vh)] sm:min-h-[min(34rem,90vh)]"
: ""
}
closeOnClickOutside
closeOnEsc={!taskMediaPreview}
footer={viewTask ? (
<div ref={taskAddMenuBottomRef} className="relative flex justify-end">
<button
@ -7740,9 +7968,13 @@ function TasksPageContent() {
<Plus size={15} />
</button>
{taskAddMenuOpen && taskAddMenuPlacement === "bottom" ? (
<div className="app-menu absolute bottom-full right-0 z-40 mb-2 w-60 rounded-xl p-1.5">
<TaskAddMenuPopover
anchorRef={taskAddMenuBottomRef}
menuRef={taskAddMenuPopoverRef}
placement="bottom"
>
{taskAddMenuItems}
</div>
</TaskAddMenuPopover>
) : null}
</div>
) : undefined}
@ -7778,9 +8010,13 @@ function TasksPageContent() {
<Plus size={14} />
</button>
{taskAddMenuOpen && taskAddMenuPlacement === "top" ? (
<div className="app-menu absolute left-0 top-full z-40 mt-2 w-60 rounded-xl p-1.5">
<TaskAddMenuPopover
anchorRef={taskAddMenuRef}
menuRef={taskAddMenuPopoverRef}
placement="top"
>
{taskAddMenuItems}
</div>
</TaskAddMenuPopover>
) : null}
</div>
{!viewTask.assignee && !viewTask.completed_at ? (
@ -8364,15 +8600,16 @@ function TasksPageContent() {
key={attachment.id}
className="flex items-center gap-2 border-b border-[var(--border-subtle)] px-3 py-2.5 last:border-b-0"
>
<TaskAttachmentThumbnail
attachment={attachment}
onPreview={setTaskMediaPreview}
/>
<button
type="button"
onClick={() => void downloadViewedTaskAttachment(attachment)}
className="flex min-w-0 flex-1 items-center gap-3 text-left"
title={`Скачать ${attachment.file_name}`}
>
<span className="app-selected flex h-8 w-8 shrink-0 items-center justify-center rounded-lg">
<FileText size={15} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-[var(--foreground)]">
{attachment.file_name}
@ -8951,6 +9188,13 @@ function TasksPageContent() {
) : null}
</Modal>
{taskMediaPreview ? (
<ChatMediaPreviewModal
preview={taskMediaPreview}
onClose={() => setTaskMediaPreview(null)}
/>
) : null}
<Modal
isOpen={coverPickerOpen}
onClose={() => {

View File

@ -2,6 +2,7 @@
import Image from "next/image";
import { X } from "lucide-react";
import { useEffect } from "react";
import type { MediaPreview } from "@/components/messages/ChatMessageItem";
@ -11,8 +12,19 @@ type ChatMediaPreviewModalProps = {
};
export default function ChatMediaPreviewModal({ preview, onClose }: ChatMediaPreviewModalProps) {
useEffect(() => {
const handleEscape = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.preventDefault();
event.stopImmediatePropagation();
onClose();
};
document.addEventListener("keydown", handleEscape, true);
return () => document.removeEventListener("keydown", handleEscape, true);
}, [onClose]);
return (
<div data-overlay-root="true" className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 p-4" onClick={onClose}>
<div data-overlay-root="true" className="fixed inset-0 z-[300] flex items-center justify-center bg-black/80 p-4" onClick={onClose}>
<button
type="button"
onClick={onClose}
@ -38,4 +50,4 @@ export default function ChatMediaPreviewModal({ preview, onClose }: ChatMediaPre
</div>
</div>
);
}
}

View File

@ -8,6 +8,7 @@ import {
ChevronLeft,
ChevronRight,
Filter,
Folder,
FolderInput,
Kanban,
LockKeyhole,
@ -92,6 +93,7 @@ function BoardRow({
onToggleNotifications,
onMove,
onViewParticipants,
groupName,
compact = false,
}: {
board: TaskBoardSummary;
@ -107,6 +109,7 @@ function BoardRow({
onToggleNotifications: () => void;
onMove: () => void;
onViewParticipants: () => void;
groupName?: string;
compact?: boolean;
}) {
if (compact) {
@ -179,6 +182,12 @@ function BoardRow({
{board.access_scope === "private" ? <LockKeyhole size={11} /> : <Users size={11} />}
{board.access_scope === "private" ? "Личная" : "Общая"}
</span>
{groupName ? (
<span className="inline-flex min-w-0 items-center gap-1" title={`Группа: ${groupName}`}>
<Folder size={11} className="shrink-0" />
<span className="max-w-32 truncate">{groupName}</span>
</span>
) : null}
</span>
</span>
</button>
@ -344,6 +353,10 @@ export default function TaskBoardNavigator({
])),
};
}, [filteredBoards, groups, unreadCounts]);
const groupNames = useMemo(
() => new Map(groups.map((group) => [group.id, group.name])),
[groups],
);
const activeFiltersCount = [unreadFilter !== "all", accessFilter !== "all"].filter(Boolean).length;
const compactNavigator = wideMode && navigatorCollapsed;
const filteredMoveGroups = useMemo(() => {
@ -691,6 +704,7 @@ export default function TaskBoardNavigator({
<BoardRow
key={board.id}
board={board}
groupName={board.group_id ? groupNames.get(board.group_id) : undefined}
active={selectedBoardId === board.id}
unread={unreadCounts[board.id] || 0}
compact={compactNavigator}