From fad399cd47e842ebe90918aca2ba5f40760d76fd Mon Sep 17 00:00:00 2001 From: lizerk Date: Fri, 7 Aug 2026 15:32:41 +0300 Subject: [PATCH] improve task board controls and media previews --- frontend/src/app/tasks/page.tsx | 274 +++++++++++++++++- .../messages/ChatMediaPreviewModal.tsx | 16 +- .../components/tasks/TaskBoardNavigator.tsx | 14 + 3 files changed, 287 insertions(+), 17 deletions(-) diff --git a/frontend/src/app/tasks/page.tsx b/frontend/src/app/tasks/page.tsx index 54b63ddc..42c0ba6f 100644 --- a/frontend/src/app/tasks/page.tsx +++ b/frontend/src/app/tasks/page.tsx @@ -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(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 ( + + + + ); + } + + return ( + + ); +} + 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} > -