diff --git a/frontend/src/app/messages/[chatId]/page.tsx b/frontend/src/app/messages/[chatId]/page.tsx index c56354d8..0944a812 100644 --- a/frontend/src/app/messages/[chatId]/page.tsx +++ b/frontend/src/app/messages/[chatId]/page.tsx @@ -12,14 +12,15 @@ import { } from "lucide-react"; import { Modal } from "@/components/ui"; import ChatDialogHeader from "@/components/messages/ChatDialogHeader"; -import ChatMediaPreviewModal from "@/components/messages/ChatMediaPreviewModal"; -import ChatMessageItem, { MediaPreview } from "@/components/messages/ChatMessageItem"; +import MediaPreviewModal from "@/components/shared/MediaPreviewModal"; +import ChatMessageItem from "@/components/messages/ChatMessageItem"; import ChatSearchPanel from "@/components/messages/ChatSearchPanel"; import CreatePollModal from "@/components/messages/CreatePollModal"; import MessageReadersModal from "@/components/messages/MessageReadersModal"; import MessageComposer from "@/components/messages/MessageComposer"; import ReactionPickerModal from "@/components/messages/ReactionPickerModal"; import { apiClient } from "@/lib/api"; +import type { MediaPreview } from "@/lib/media"; import { getTaskColumnDisplayName, getTaskDestinationColumns } from "@/lib/tasks/columns"; import type { ChatMessageSearchResponse, @@ -1638,7 +1639,7 @@ function MessageDialogPageContent() { )} - {mediaPreview ? setMediaPreview(null)} /> : null} + {mediaPreview ? setMediaPreview(null)} /> : null} {selectedActionMessage ? ( setIsReadersModalOpen(false)} readers={selectedActionReaders} /> diff --git a/frontend/src/app/tasks/page.tsx b/frontend/src/app/tasks/page.tsx index 406fd0ef..d5d2ba91 100644 --- a/frontend/src/app/tasks/page.tsx +++ b/frontend/src/app/tasks/page.tsx @@ -86,8 +86,7 @@ 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 MediaPreviewModal from "@/components/shared/MediaPreviewModal"; import { RequestAvatar } from "@/components/requests/RequestAvatar"; import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls"; import { Modal } from "@/components/ui"; @@ -96,6 +95,7 @@ import { useUser } from "@/contexts/UserContext"; import { useTaskBoardScroll } from "@/hooks/useTaskBoardScroll"; import { useTaskBoardRealtime } from "@/hooks/useTaskBoardRealtime"; import { apiClient } from "@/lib/api"; +import type { MediaPreview } from "@/lib/media"; import { displayUserName, formatDate, formatDateTime, formatMoney } from "@/lib/shared"; import { buildTaskBoardLayout, taskPlacementKey } from "@/lib/tasks/boardLayout"; import { resolveMediaUrl } from "@/lib/url"; @@ -3820,6 +3820,8 @@ function TasksPageContent() { const [taskChecklistLoading, setTaskChecklistLoading] = useState(false); const [taskChecklistSaving, setTaskChecklistSaving] = useState(false); const [taskChecklistDraft, setTaskChecklistDraft] = useState(""); + const [editingChecklistItemId, setEditingChecklistItemId] = useState(null); + const [editingChecklistItemDraft, setEditingChecklistItemDraft] = useState(""); const [activityOpen, setActivityOpen] = useState(false); const [taskDetailsOpen, setTaskDetailsOpen] = useState(false); const [taskActivities, setTaskActivities] = useState([]); @@ -4835,6 +4837,8 @@ function TasksPageContent() { setTaskChecklist([]); setTaskChecklistLoaded(false); setTaskChecklistDraft(""); + setEditingChecklistItemId(null); + setEditingChecklistItemDraft(""); setCommentDraft(""); setCommentFiles([]); setCoverPickerOpen(false); @@ -5018,6 +5022,8 @@ function TasksPageContent() { setTaskChecklist([]); setTaskChecklistLoaded(false); setTaskChecklistDraft(""); + setEditingChecklistItemId(null); + setEditingChecklistItemDraft(""); setTaskActivities([]); setTaskAutomationButtons([]); setTaskDetailsOpen(false); @@ -5069,6 +5075,8 @@ function TasksPageContent() { setTaskMediaPreview(null); setTaskChecklist([]); setTaskChecklistLoaded(false); + setEditingChecklistItemId(null); + setEditingChecklistItemDraft(""); setTaskActivities([]); setTaskAutomationButtons([]); setRunningTaskAutomationId(null); @@ -6406,6 +6414,54 @@ function TasksPageContent() { } }, [activityOpen, board, loadBoard, loadTaskActivity, taskChecklistSaving, viewTask]); + const startEditingViewedTaskChecklistItem = useCallback((item: TaskChecklistItem) => { + if (taskChecklistSaving) return; + setEditingChecklistItemId(item.id); + setEditingChecklistItemDraft(item.title); + }, [taskChecklistSaving]); + + const cancelEditingViewedTaskChecklistItem = useCallback(() => { + if (taskChecklistSaving) return; + setEditingChecklistItemId(null); + setEditingChecklistItemDraft(""); + }, [taskChecklistSaving]); + + const saveViewedTaskChecklistItem = useCallback(async (item: TaskChecklistItem) => { + const title = editingChecklistItemDraft.trim(); + if (!board || !viewTask || !title || taskChecklistSaving) return; + if (title === item.title) { + setEditingChecklistItemId(null); + setEditingChecklistItemDraft(""); + return; + } + + setTaskChecklistSaving(true); + setError(null); + try { + const updated = await apiClient.updateTaskChecklistItem(viewTask.id, item.id, { title }); + setTaskChecklist((current) => current.map((currentItem) => ( + currentItem.id === updated.id ? updated : currentItem + ))); + setTaskChecklistLoaded(true); + setEditingChecklistItemId(null); + setEditingChecklistItemDraft(""); + await loadBoard(board.id); + if (activityOpen) await loadTaskActivity(viewTask.id); + } catch (checklistError) { + setError(getTaskError(checklistError, "Не удалось переименовать пункт чек-листа")); + } finally { + setTaskChecklistSaving(false); + } + }, [ + activityOpen, + board, + editingChecklistItemDraft, + loadBoard, + loadTaskActivity, + taskChecklistSaving, + viewTask, + ]); + const deleteViewedTaskChecklistItem = useCallback(async (item: TaskChecklistItem) => { if (!board || !viewTask || taskChecklistSaving) return; @@ -6415,6 +6471,10 @@ function TasksPageContent() { await apiClient.deleteTaskChecklistItem(viewTask.id, item.id); setTaskChecklist((current) => current.filter((currentItem) => currentItem.id !== item.id)); setTaskChecklistLoaded(true); + if (editingChecklistItemId === item.id) { + setEditingChecklistItemId(null); + setEditingChecklistItemDraft(""); + } await loadBoard(board.id); if (activityOpen) await loadTaskActivity(viewTask.id); } catch (checklistError) { @@ -6422,7 +6482,15 @@ function TasksPageContent() { } finally { setTaskChecklistSaving(false); } - }, [activityOpen, board, loadBoard, loadTaskActivity, taskChecklistSaving, viewTask]); + }, [ + activityOpen, + board, + editingChecklistItemId, + loadBoard, + loadTaskActivity, + taskChecklistSaving, + viewTask, + ]); const addTaskFormChecklistItem = useCallback(() => { const title = taskFormChecklistDraft.trim(); @@ -9024,23 +9092,84 @@ function TasksPageContent() { className="h-4 w-4 shrink-0 accent-sky-500" aria-label={item.is_completed ? "Вернуть пункт в работу" : "Отметить пункт выполненным"} /> - - {item.title} - - + {editingChecklistItemId === item.id ? ( + setEditingChecklistItemDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void saveViewedTaskChecklistItem(item); + } else if (event.key === "Escape") { + event.preventDefault(); + cancelEditingViewedTaskChecklistItem(); + } + }} + disabled={taskChecklistSaving} + className="app-input min-w-0 flex-1 rounded-lg px-2.5 py-1.5 text-sm" + aria-label={`Изменить пункт ${item.title}`} + /> + ) : ( + + {item.title} + + )} + {editingChecklistItemId === item.id ? ( + <> + + + + ) : ( + <> + + + + )} ))} @@ -9752,7 +9881,7 @@ function TasksPageContent() { {taskMediaPreview ? ( - setTaskMediaPreview(null)} /> diff --git a/frontend/src/components/documents/DocumentPreview.tsx b/frontend/src/components/documents/DocumentPreview.tsx index e35bf5bc..33d2345a 100644 --- a/frontend/src/components/documents/DocumentPreview.tsx +++ b/frontend/src/components/documents/DocumentPreview.tsx @@ -6,8 +6,10 @@ import { Document as PDFDocument, Page, pdfjs } from "react-pdf"; import { ChevronLeft, ChevronRight, Download, FileAudio, FileText, FileVideo, Table, X } from "lucide-react"; import "react-pdf/dist/Page/AnnotationLayer.css"; import "react-pdf/dist/Page/TextLayer.css"; +import MediaPreviewModal from "@/components/shared/MediaPreviewModal"; import { Modal } from "@/components/ui"; import { apiClient } from "@/lib/api"; +import { getFileExtension, getMediaKind } from "@/lib/media"; if (typeof window !== "undefined") { pdfjs.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs"; @@ -32,11 +34,8 @@ interface ExcelSheet { type PreviewType = "pdf" | "image" | "word" | "spreadsheet" | "text" | "video" | "audio" | "unsupported"; -const IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "ico", "avif"]); const WORD_EXTENSIONS = new Set(["docx"]); const SPREADSHEET_EXTENSIONS = new Set(["xlsx", "xlsm", "ods", "csv", "tsv"]); -const VIDEO_EXTENSIONS = new Set(["mp4", "webm", "ogv", "mov", "m4v"]); -const AUDIO_EXTENSIONS = new Set(["mp3", "wav", "ogg", "oga", "m4a", "aac", "flac", "webm"]); const TEXT_EXTENSIONS = new Set([ "txt", "text", @@ -89,22 +88,15 @@ const TEXT_EXTENSIONS = new Set([ ]); const TEXT_FILE_NAMES = new Set(["dockerfile", "makefile", "readme", "license", ".gitignore", ".env"]); -function getFileExtension(fileName: string): string { - const cleanName = fileName.toLowerCase().split("?")[0].split("#")[0]; - const match = cleanName.match(/\.([^.]+)$/); - return match?.[1] || ""; -} - function getPreviewType(fileName: string): PreviewType { const lowerName = fileName.toLowerCase(); const extension = getFileExtension(fileName); + const mediaKind = getMediaKind({ fileName }); if (extension === "pdf") return "pdf"; - if (IMAGE_EXTENSIONS.has(extension)) return "image"; if (WORD_EXTENSIONS.has(extension)) return "word"; if (SPREADSHEET_EXTENSIONS.has(extension)) return "spreadsheet"; - if (VIDEO_EXTENSIONS.has(extension)) return "video"; - if (AUDIO_EXTENSIONS.has(extension)) return "audio"; + if (mediaKind !== "file") return mediaKind; if (TEXT_EXTENSIONS.has(extension) || TEXT_FILE_NAMES.has(lowerName)) return "text"; return "unsupported"; @@ -539,6 +531,22 @@ export function DocumentPreviewPane({ fileUrl, fileName, className = "" }: Docum } export function DocumentPreview({ fileUrl, fileName, onClose }: DocumentPreviewProps) { + const previewType = getPreviewType(fileName); + + if (previewType === "image" || previewType === "video") { + return ( + {})} + /> + ); + } + return ( {})} noHeader noPadding size="xl" className="h-[90vh]">
diff --git a/frontend/src/components/messages/ChatMediaPreviewModal.tsx b/frontend/src/components/messages/ChatMediaPreviewModal.tsx deleted file mode 100644 index ef71dfdd..00000000 --- a/frontend/src/components/messages/ChatMediaPreviewModal.tsx +++ /dev/null @@ -1,53 +0,0 @@ -"use client"; - -import Image from "next/image"; -import { X } from "lucide-react"; -import { useEffect } from "react"; - -import type { MediaPreview } from "@/components/messages/ChatMessageItem"; - -type ChatMediaPreviewModalProps = { - preview: MediaPreview; - onClose: () => void; -}; - -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 ( -
- - -
event.stopPropagation()}> - {preview.type === "image" ? ( - {preview.name} - ) : ( -
-
- ); -} diff --git a/frontend/src/components/messages/ChatMessageItem.tsx b/frontend/src/components/messages/ChatMessageItem.tsx index 12d52e66..ef46adde 100644 --- a/frontend/src/components/messages/ChatMessageItem.tsx +++ b/frontend/src/components/messages/ChatMessageItem.tsx @@ -34,14 +34,9 @@ import { isVideoAttachment, messageTime, } from "@/lib/messages/messageUtils"; +import type { MediaPreview } from "@/lib/media"; import type { Message } from "@/types/api"; -export type MediaPreview = { - type: "image" | "video"; - src: string; - name: string; -}; - function MediaTypeBadge({ type }: { type: "image" | "video" }) { const isVideo = type === "video"; const Icon = isVideo ? PlayCircle : ImageIcon; @@ -368,7 +363,7 @@ export default function ChatMessageItem({ key={attachment.id} type="button" onClick={() => onOpenMediaPreview({ type: "image", src: fileUrl, name: attachment.file_name })} - className="relative block w-full overflow-hidden rounded-lg" + className="relative block w-full overflow-hidden rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70" > onOpenMediaPreview({ type: "video", src: fileUrl, name: attachment.file_name })} - className="relative block w-full overflow-hidden rounded-lg" + className="relative block w-full overflow-hidden rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70" >
); } diff --git a/frontend/src/components/requests/RequestAttachmentPreviewModal.tsx b/frontend/src/components/requests/RequestAttachmentPreviewModal.tsx index bedba636..b567528e 100644 --- a/frontend/src/components/requests/RequestAttachmentPreviewModal.tsx +++ b/frontend/src/components/requests/RequestAttachmentPreviewModal.tsx @@ -1,6 +1,7 @@ -import Image from "next/image"; +import MediaPreviewModal from "@/components/shared/MediaPreviewModal"; import { Modal } from "@/components/ui"; import type { RequestAttachmentPreview } from "@/hooks/useRequestsPage"; +import { getMediaKind } from "@/lib/media"; import { resolveMediaUrl } from "@/lib/url"; import { FileSignature } from "lucide-react"; @@ -15,8 +16,22 @@ export function RequestAttachmentPreviewModal({ }: RequestAttachmentPreviewModalProps) { if (!preview) return null; - const extension = preview.url.split(".").pop()?.toLowerCase() || ""; const previewUrl = resolveMediaUrl(preview.url); + const mediaKind = getMediaKind({ fileName: preview.name || preview.url }); + + if (mediaKind === "image" || mediaKind === "video") { + return ( + + ); + } return (
- {["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp"].includes(extension) ? ( -
- {preview.name} -
- ) : ["mp4", "webm", "ogg", "mov"].includes(extension) ? ( -