Add checklist item editing and unify media previews
This commit is contained in:
parent
3c5a8c87e2
commit
1aac1b0400
@ -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() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{mediaPreview ? <ChatMediaPreviewModal preview={mediaPreview} onClose={() => setMediaPreview(null)} /> : null}
|
||||
{mediaPreview ? <MediaPreviewModal preview={mediaPreview} onClose={() => setMediaPreview(null)} /> : null}
|
||||
|
||||
{selectedActionMessage ? (
|
||||
<MessageReadersModal isOpen={isReadersModalOpen} onClose={() => setIsReadersModalOpen(false)} readers={selectedActionReaders} />
|
||||
|
||||
@ -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<number | null>(null);
|
||||
const [editingChecklistItemDraft, setEditingChecklistItemDraft] = useState("");
|
||||
const [activityOpen, setActivityOpen] = useState(false);
|
||||
const [taskDetailsOpen, setTaskDetailsOpen] = useState(false);
|
||||
const [taskActivities, setTaskActivities] = useState<TaskActivity[]>([]);
|
||||
@ -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 ? "Вернуть пункт в работу" : "Отметить пункт выполненным"}
|
||||
/>
|
||||
<span className={`app-text-wrap min-w-0 flex-1 text-sm ${
|
||||
item.is_completed
|
||||
? "app-text-muted line-through"
|
||||
: "text-[var(--foreground)]"
|
||||
}`}>
|
||||
{item.title}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void deleteViewedTaskChecklistItem(item)}
|
||||
disabled={taskChecklistSaving}
|
||||
className="app-icon-button flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-[var(--danger-foreground)] disabled:opacity-50"
|
||||
title="Удалить пункт"
|
||||
aria-label={`Удалить пункт ${item.title}`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
{editingChecklistItemId === item.id ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={editingChecklistItemDraft}
|
||||
onChange={(event) => 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}`}
|
||||
/>
|
||||
) : (
|
||||
<span className={`app-text-wrap min-w-0 flex-1 text-sm ${
|
||||
item.is_completed
|
||||
? "app-text-muted line-through"
|
||||
: "text-[var(--foreground)]"
|
||||
}`}>
|
||||
{item.title}
|
||||
</span>
|
||||
)}
|
||||
{editingChecklistItemId === item.id ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void saveViewedTaskChecklistItem(item)}
|
||||
disabled={taskChecklistSaving || !editingChecklistItemDraft.trim()}
|
||||
className="app-icon-button flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-emerald-400 disabled:opacity-50"
|
||||
title="Сохранить пункт"
|
||||
aria-label={`Сохранить пункт ${item.title}`}
|
||||
>
|
||||
{taskChecklistSaving ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Check size={14} />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelEditingViewedTaskChecklistItem}
|
||||
disabled={taskChecklistSaving}
|
||||
className="app-icon-button flex h-8 w-8 shrink-0 items-center justify-center rounded-lg disabled:opacity-50"
|
||||
title="Отменить редактирование"
|
||||
aria-label={`Отменить редактирование пункта ${item.title}`}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startEditingViewedTaskChecklistItem(item)}
|
||||
disabled={taskChecklistSaving}
|
||||
className="app-icon-button flex h-8 w-8 shrink-0 items-center justify-center rounded-lg disabled:opacity-50"
|
||||
title="Редактировать пункт"
|
||||
aria-label={`Редактировать пункт ${item.title}`}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void deleteViewedTaskChecklistItem(item)}
|
||||
disabled={taskChecklistSaving}
|
||||
className="app-icon-button flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-[var(--danger-foreground)] disabled:opacity-50"
|
||||
title="Удалить пункт"
|
||||
aria-label={`Удалить пункт ${item.title}`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -9752,7 +9881,7 @@ function TasksPageContent() {
|
||||
</Modal>
|
||||
|
||||
{taskMediaPreview ? (
|
||||
<ChatMediaPreviewModal
|
||||
<MediaPreviewModal
|
||||
preview={taskMediaPreview}
|
||||
onClose={() => setTaskMediaPreview(null)}
|
||||
/>
|
||||
|
||||
@ -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 (
|
||||
<MediaPreviewModal
|
||||
preview={{
|
||||
type: previewType,
|
||||
src: fileUrl,
|
||||
name: fileName,
|
||||
downloadUrl: fileUrl,
|
||||
}}
|
||||
onClose={onClose ?? (() => {})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen onClose={onClose ?? (() => {})} noHeader noPadding size="xl" className="h-[90vh]">
|
||||
<div className="app-divider flex shrink-0 items-center justify-between border-b px-3 py-3 sm:px-6 sm:py-4">
|
||||
|
||||
@ -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 (
|
||||
<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}
|
||||
className="absolute right-4 top-4 inline-flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20"
|
||||
aria-label="Закрыть предпросмотр"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
|
||||
<div className="max-h-full max-w-[92vw]" onClick={(event) => event.stopPropagation()}>
|
||||
{preview.type === "image" ? (
|
||||
<Image
|
||||
src={preview.src}
|
||||
alt={preview.name}
|
||||
width={1600}
|
||||
height={1200}
|
||||
unoptimized
|
||||
className="max-h-[88vh] max-w-[92vw] rounded-lg object-contain"
|
||||
/>
|
||||
) : (
|
||||
<video controls autoPlay className="max-h-[88vh] max-w-[92vw] rounded-lg bg-black" src={preview.src} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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"
|
||||
>
|
||||
<MediaTypeBadge type="image" />
|
||||
<Image
|
||||
@ -413,7 +408,7 @@ export default function ChatMessageItem({
|
||||
key={attachment.id}
|
||||
type="button"
|
||||
onClick={() => 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"
|
||||
>
|
||||
<MediaTypeBadge type="video" />
|
||||
<video
|
||||
|
||||
@ -4,22 +4,12 @@ import Image from "next/image";
|
||||
import { Download, Eye, FileText, Loader2, Play } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import ChatMediaPreviewModal from "@/components/messages/ChatMediaPreviewModal";
|
||||
import type { MediaPreview } from "@/components/messages/ChatMessageItem";
|
||||
import MediaPreviewModal from "@/components/shared/MediaPreviewModal";
|
||||
import { useNearViewport } from "@/hooks/useNearViewport";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { getMediaKind, type MediaPreview } from "@/lib/media";
|
||||
import type { ProcurementItemAttachment } from "@/types/api";
|
||||
|
||||
function isImage(attachment: ProcurementItemAttachment): boolean {
|
||||
if (attachment.mime_type?.toLowerCase().startsWith("image/")) return true;
|
||||
return /\.(avif|bmp|gif|heic|heif|jpe?g|png|webp)$/i.test(attachment.file_name);
|
||||
}
|
||||
|
||||
function isVideo(attachment: ProcurementItemAttachment): boolean {
|
||||
if (attachment.mime_type?.toLowerCase().startsWith("video/")) return true;
|
||||
return /\.(m4v|mkv|mov|mp4|mpeg|mpg|ogv|webm)$/i.test(attachment.file_name);
|
||||
}
|
||||
|
||||
function formatFileSize(size: number): string {
|
||||
if (size < 1024) return `${size} Б`;
|
||||
if (size < 1024 * 1024) return `${Math.ceil(size / 1024)} КБ`;
|
||||
@ -37,8 +27,12 @@ export function ProcurementCommentAttachment({
|
||||
attachment: ProcurementItemAttachment;
|
||||
onDownload: (attachment: ProcurementItemAttachment) => void | Promise<void>;
|
||||
}) {
|
||||
const image = isImage(attachment);
|
||||
const video = isVideo(attachment);
|
||||
const mediaKind = getMediaKind({
|
||||
fileName: attachment.file_name,
|
||||
mimeType: attachment.mime_type,
|
||||
});
|
||||
const image = mediaKind === "image";
|
||||
const video = mediaKind === "video";
|
||||
const media = image || video;
|
||||
const [thumbnailSource, setThumbnailSource] = useState<string | null>(null);
|
||||
const [thumbnailFailed, setThumbnailFailed] = useState(false);
|
||||
@ -105,7 +99,7 @@ export function ProcurementCommentAttachment({
|
||||
type="button"
|
||||
onClick={() => void openPreview()}
|
||||
disabled={!thumbnailSource || thumbnailFailed || previewLoading}
|
||||
className="group relative block aspect-video min-h-32 w-full overflow-hidden bg-[var(--surface-secondary)] disabled:cursor-default"
|
||||
className="group relative block aspect-video min-h-32 w-full overflow-hidden bg-[var(--surface-secondary)] outline-none focus-visible:ring-2 focus-visible:ring-sky-400/70 disabled:cursor-default"
|
||||
title={`Просмотреть ${attachment.file_name}`}
|
||||
>
|
||||
{thumbnailSource ? (
|
||||
@ -139,7 +133,7 @@ export function ProcurementCommentAttachment({
|
||||
</span>
|
||||
<Download size={13} className="app-text-muted shrink-0" />
|
||||
</button>
|
||||
{preview ? <ChatMediaPreviewModal preview={preview} onClose={closePreview} /> : null}
|
||||
{preview ? <MediaPreviewModal preview={preview} onClose={closePreview} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<MediaPreviewModal
|
||||
preview={{
|
||||
type: mediaKind,
|
||||
src: previewUrl,
|
||||
name: preview.name,
|
||||
downloadUrl: previewUrl,
|
||||
}}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@ -35,20 +50,7 @@ export function RequestAttachmentPreviewModal({
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp"].includes(extension) ? (
|
||||
<div className="relative mx-auto h-[70vh] overflow-hidden rounded-lg bg-[var(--surface-secondary)]">
|
||||
<Image
|
||||
src={previewUrl}
|
||||
alt={preview.name}
|
||||
fill
|
||||
unoptimized
|
||||
sizes="(max-width: 1024px) 100vw, 960px"
|
||||
className="object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : ["mp4", "webm", "ogg", "mov"].includes(extension) ? (
|
||||
<video src={previewUrl} controls className="mx-auto max-h-[70vh] rounded-lg" />
|
||||
) : ["mp3", "wav", "aac"].includes(extension) ? (
|
||||
{mediaKind === "audio" ? (
|
||||
<audio src={previewUrl} controls className="mx-auto mt-8" />
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-3 py-12 text-center">
|
||||
|
||||
123
frontend/src/components/shared/MediaPreviewModal.tsx
Normal file
123
frontend/src/components/shared/MediaPreviewModal.tsx
Normal file
@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { Download, X } from "lucide-react";
|
||||
import { useEffect, useRef, useSyncExternalStore } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import type { MediaPreview } from "@/lib/media";
|
||||
|
||||
type MediaPreviewModalProps = {
|
||||
preview: MediaPreview;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const subscribeToClient = () => () => undefined;
|
||||
const getClientSnapshot = () => true;
|
||||
const getServerSnapshot = () => false;
|
||||
|
||||
export default function MediaPreviewModal({ preview, onClose }: MediaPreviewModalProps) {
|
||||
const closeButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const onCloseRef = useRef(onClose);
|
||||
const canUseDOM = useSyncExternalStore(
|
||||
subscribeToClient,
|
||||
getClientSnapshot,
|
||||
getServerSnapshot,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onCloseRef.current = onClose;
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
const previousFocus = document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null;
|
||||
const shouldRestoreFocus = previousFocus?.matches(":focus-visible") ?? false;
|
||||
document.body.style.overflow = "hidden";
|
||||
closeButtonRef.current?.focus();
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
onCloseRef.current();
|
||||
};
|
||||
document.addEventListener("keydown", handleEscape, true);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleEscape, true);
|
||||
document.body.style.overflow = previousOverflow;
|
||||
if (shouldRestoreFocus) previousFocus?.focus();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const downloadUrl = preview.downloadUrl;
|
||||
|
||||
if (!canUseDOM) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
data-overlay-root="true"
|
||||
className="fixed inset-0 z-[300] flex cursor-zoom-out items-center justify-center bg-black/85 p-3 backdrop-blur-[2px] sm:p-5"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`Просмотр ${preview.type === "image" ? "изображения" : "видео"}: ${preview.name}`}
|
||||
>
|
||||
<div
|
||||
className="absolute right-3 top-3 z-10 flex items-center gap-2 sm:right-5 sm:top-5"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{downloadUrl ? (
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download={preview.name}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70"
|
||||
title="Скачать"
|
||||
aria-label={`Скачать ${preview.name}`}
|
||||
>
|
||||
<Download size={18} />
|
||||
</a>
|
||||
) : null}
|
||||
<button
|
||||
ref={closeButtonRef}
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70"
|
||||
title="Закрыть"
|
||||
aria-label="Закрыть предпросмотр"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{preview.type === "image" ? (
|
||||
<Image
|
||||
src={preview.src}
|
||||
alt={preview.name}
|
||||
width={1600}
|
||||
height={1200}
|
||||
unoptimized
|
||||
draggable={false}
|
||||
className="h-auto w-auto max-h-[86vh] max-w-[90vw] cursor-zoom-out select-none rounded-lg object-contain shadow-2xl"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="max-h-full max-w-[90vw] cursor-default"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<video
|
||||
controls
|
||||
autoPlay
|
||||
playsInline
|
||||
className="max-h-[86vh] max-w-[90vw] rounded-lg bg-black shadow-2xl"
|
||||
src={preview.src}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@ -4,22 +4,12 @@ import Image from "next/image";
|
||||
import { Download, Eye, FileText, Loader2, Play } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import ChatMediaPreviewModal from "@/components/messages/ChatMediaPreviewModal";
|
||||
import type { MediaPreview } from "@/components/messages/ChatMessageItem";
|
||||
import MediaPreviewModal from "@/components/shared/MediaPreviewModal";
|
||||
import { useNearViewport } from "@/hooks/useNearViewport";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { getMediaKind, type MediaPreview } from "@/lib/media";
|
||||
import type { TaskAttachment } from "@/types/api";
|
||||
|
||||
function isImage(attachment: TaskAttachment): boolean {
|
||||
if (attachment.mime_type?.toLowerCase().startsWith("image/")) return true;
|
||||
return /\.(avif|bmp|gif|heic|heif|jpe?g|png|webp)$/i.test(attachment.file_name);
|
||||
}
|
||||
|
||||
function isVideo(attachment: TaskAttachment): boolean {
|
||||
if (attachment.mime_type?.toLowerCase().startsWith("video/")) return true;
|
||||
return /\.(m4v|mkv|mov|mp4|mpeg|mpg|ogv|webm)$/i.test(attachment.file_name);
|
||||
}
|
||||
|
||||
function formatFileSize(size: number): string {
|
||||
if (size < 1024) return `${size} Б`;
|
||||
if (size < 1024 * 1024) return `${Math.ceil(size / 1024)} КБ`;
|
||||
@ -37,8 +27,12 @@ export default function TaskCommentAttachment({
|
||||
attachment: TaskAttachment;
|
||||
onDownload: (attachment: TaskAttachment) => void | Promise<void>;
|
||||
}) {
|
||||
const image = isImage(attachment);
|
||||
const video = isVideo(attachment);
|
||||
const mediaKind = getMediaKind({
|
||||
fileName: attachment.file_name,
|
||||
mimeType: attachment.mime_type,
|
||||
});
|
||||
const image = mediaKind === "image";
|
||||
const video = mediaKind === "video";
|
||||
const media = image || video;
|
||||
const [thumbnailSource, setThumbnailSource] = useState<string | null>(null);
|
||||
const [thumbnailFailed, setThumbnailFailed] = useState(false);
|
||||
@ -105,7 +99,7 @@ export default function TaskCommentAttachment({
|
||||
type="button"
|
||||
onClick={() => void openPreview()}
|
||||
disabled={!thumbnailSource || thumbnailFailed || previewLoading}
|
||||
className="group relative block aspect-video min-h-32 w-full overflow-hidden bg-[var(--surface-secondary)] disabled:cursor-default"
|
||||
className="group relative block aspect-video min-h-32 w-full overflow-hidden bg-[var(--surface-secondary)] outline-none focus-visible:ring-2 focus-visible:ring-sky-400/70 disabled:cursor-default"
|
||||
title={`Просмотреть ${attachment.file_name}`}
|
||||
>
|
||||
{thumbnailSource ? (
|
||||
@ -139,7 +133,7 @@ export default function TaskCommentAttachment({
|
||||
</span>
|
||||
<Download size={13} className="app-text-muted shrink-0" />
|
||||
</button>
|
||||
{preview ? <ChatMediaPreviewModal preview={preview} onClose={closePreview} /> : null}
|
||||
{preview ? <MediaPreviewModal preview={preview} onClose={closePreview} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -14,19 +14,10 @@ import {
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { getMediaKind } from "@/lib/media";
|
||||
import { displayUserName } from "@/lib/shared";
|
||||
import type { TaskAttachment, TaskCover } from "@/types/api";
|
||||
|
||||
function isImage(attachment: TaskAttachment): boolean {
|
||||
if (attachment.mime_type?.toLowerCase().startsWith("image/")) return true;
|
||||
return /\.(avif|bmp|gif|heic|heif|jpe?g|png|webp)$/i.test(attachment.file_name);
|
||||
}
|
||||
|
||||
function isVideo(attachment: TaskAttachment): boolean {
|
||||
if (attachment.mime_type?.toLowerCase().startsWith("video/")) return true;
|
||||
return /\.(m4v|mkv|mov|mp4|mpeg|mpg|ogv|webm)$/i.test(attachment.file_name);
|
||||
}
|
||||
|
||||
const coverVisibilityCallbacks = new Map<Element, () => void>();
|
||||
let coverVisibilityObserver: IntersectionObserver | null = null;
|
||||
|
||||
@ -72,7 +63,10 @@ function AuthenticatedCoverMedia({
|
||||
const [source, setSource] = useState<string | null>(null);
|
||||
const [sourceIsOriginal, setSourceIsOriginal] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const video = isVideo(attachment);
|
||||
const video = getMediaKind({
|
||||
fileName: attachment.file_name,
|
||||
mimeType: attachment.mime_type,
|
||||
}) === "video";
|
||||
const attachmentId = attachment.id;
|
||||
const thumbnailUrl = attachment.thumbnail_url;
|
||||
const downloadUrl = attachment.download_url;
|
||||
@ -192,9 +186,13 @@ export default function TaskCoverPreview({
|
||||
className?: string;
|
||||
}) {
|
||||
const commentMedia = useMemo(
|
||||
() => cover.comment?.attachments.find((attachment) => (
|
||||
isImage(attachment) || isVideo(attachment)
|
||||
)),
|
||||
() => cover.comment?.attachments.find((attachment) => {
|
||||
const kind = getMediaKind({
|
||||
fileName: attachment.file_name,
|
||||
mimeType: attachment.mime_type,
|
||||
});
|
||||
return kind === "image" || kind === "video";
|
||||
}),
|
||||
[cover.comment?.attachments],
|
||||
);
|
||||
const media = cover.kind === "attachment" ? cover.attachment : commentMedia;
|
||||
|
||||
21
frontend/src/lib/media.test.js
Normal file
21
frontend/src/lib/media.test.js
Normal file
@ -0,0 +1,21 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { getFileExtension, getMediaKind } from "./media.ts";
|
||||
|
||||
test("media kind prefers explicit MIME and declared types", () => {
|
||||
assert.equal(getMediaKind({ fileName: "file.bin", mimeType: "image/jpeg" }), "image");
|
||||
assert.equal(getMediaKind({ fileName: "file.bin", declaredType: "video" }), "video");
|
||||
assert.equal(getMediaKind({ fileName: "file.bin", mimeType: "audio/ogg" }), "audio");
|
||||
});
|
||||
|
||||
test("media kind is shared across common image, video and audio extensions", () => {
|
||||
assert.equal(getMediaKind({ fileName: "photo.webp" }), "image");
|
||||
assert.equal(getMediaKind({ fileName: "clip.webm" }), "video");
|
||||
assert.equal(getMediaKind({ fileName: "voice.ogg" }), "audio");
|
||||
assert.equal(getMediaKind({ fileName: "document.pdf" }), "file");
|
||||
});
|
||||
|
||||
test("file extension ignores query strings and fragments", () => {
|
||||
assert.equal(getFileExtension("PHOTO.JPEG?download=1#preview"), "jpeg");
|
||||
});
|
||||
49
frontend/src/lib/media.ts
Normal file
49
frontend/src/lib/media.ts
Normal file
@ -0,0 +1,49 @@
|
||||
export type MediaKind = "image" | "video" | "audio" | "file";
|
||||
|
||||
export type MediaPreview = {
|
||||
type: Extract<MediaKind, "image" | "video">;
|
||||
src: string;
|
||||
name: string;
|
||||
downloadUrl?: string;
|
||||
};
|
||||
|
||||
type MediaDescriptor = {
|
||||
fileName?: string | null;
|
||||
mimeType?: string | null;
|
||||
declaredType?: string | null;
|
||||
};
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set([
|
||||
"avif", "bmp", "gif", "heic", "heif", "ico", "jpeg", "jpg", "png", "svg", "webp",
|
||||
]);
|
||||
const VIDEO_EXTENSIONS = new Set([
|
||||
"3gp", "avi", "m4v", "mkv", "mov", "mp4", "mpeg", "mpg", "ogv", "webm",
|
||||
]);
|
||||
const AUDIO_EXTENSIONS = new Set([
|
||||
"aac", "flac", "m4a", "mp3", "oga", "ogg", "wav",
|
||||
]);
|
||||
|
||||
export function getFileExtension(fileName?: string | null): string {
|
||||
const cleanName = (fileName || "").toLowerCase().split("?")[0].split("#")[0];
|
||||
const match = cleanName.match(/\.([^.]+)$/);
|
||||
return match?.[1] || "";
|
||||
}
|
||||
|
||||
export function getMediaKind({
|
||||
fileName,
|
||||
mimeType,
|
||||
declaredType,
|
||||
}: MediaDescriptor): MediaKind {
|
||||
const mime = (mimeType || "").toLowerCase();
|
||||
const type = (declaredType || "").toLowerCase();
|
||||
|
||||
if (mime.startsWith("image/") || type === "image") return "image";
|
||||
if (mime.startsWith("video/") || type === "video") return "video";
|
||||
if (mime.startsWith("audio/") || type === "audio") return "audio";
|
||||
|
||||
const extension = getFileExtension(fileName);
|
||||
if (IMAGE_EXTENSIONS.has(extension)) return "image";
|
||||
if (VIDEO_EXTENSIONS.has(extension)) return "video";
|
||||
if (AUDIO_EXTENSIONS.has(extension)) return "audio";
|
||||
return "file";
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import type { Message, MessageAttachment, MessageReplyPreview } from "@/types/api";
|
||||
import { getMediaKind } from "@/lib/media";
|
||||
|
||||
type ReplyPreviewSource = Message | MessageReplyPreview;
|
||||
|
||||
@ -70,25 +71,27 @@ export function formatFileSize(size?: number): string {
|
||||
}
|
||||
|
||||
export function isImageAttachment(attachment: MessageAttachment): boolean {
|
||||
const mime = (attachment.mime_type || "").toLowerCase();
|
||||
const type = (attachment.file_type || "").toLowerCase();
|
||||
const name = (attachment.file_name || "").toLowerCase();
|
||||
const byExt = /\.(png|jpe?g|gif|webp|bmp|svg|avif)$/i.test(name);
|
||||
return mime.startsWith("image/") || type === "image" || byExt;
|
||||
return getMediaKind({
|
||||
fileName: attachment.file_name,
|
||||
mimeType: attachment.mime_type,
|
||||
declaredType: attachment.file_type,
|
||||
}) === "image";
|
||||
}
|
||||
|
||||
export function isVideoAttachment(attachment: MessageAttachment): boolean {
|
||||
const mime = (attachment.mime_type || "").toLowerCase();
|
||||
const type = (attachment.file_type || "").toLowerCase();
|
||||
const name = (attachment.file_name || "").toLowerCase();
|
||||
const byExt = /\.(mp4|webm|mov|m4v|avi|mkv|3gp|mpeg|mpg)$/i.test(name);
|
||||
return mime.startsWith("video/") || type === "video" || byExt;
|
||||
return getMediaKind({
|
||||
fileName: attachment.file_name,
|
||||
mimeType: attachment.mime_type,
|
||||
declaredType: attachment.file_type,
|
||||
}) === "video";
|
||||
}
|
||||
|
||||
export function isAudioAttachment(attachment: MessageAttachment): boolean {
|
||||
const mime = (attachment.mime_type || "").toLowerCase();
|
||||
const type = (attachment.file_type || "").toLowerCase();
|
||||
return mime.startsWith("audio/") || type === "audio";
|
||||
return getMediaKind({
|
||||
fileName: attachment.file_name,
|
||||
mimeType: attachment.mime_type,
|
||||
declaredType: attachment.file_type,
|
||||
}) === "audio";
|
||||
}
|
||||
|
||||
export function getMessagePreviewText(message: Message): string {
|
||||
@ -145,4 +148,4 @@ export function getReplyToId(message: Message): number | null {
|
||||
return message.reply_to_message.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user