Add checklist item editing and unify media previews

This commit is contained in:
lizerk 2026-08-17 14:33:21 +03:00
parent 3c5a8c87e2
commit 1aac1b0400
13 changed files with 437 additions and 173 deletions

View File

@ -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} />

View File

@ -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)}
/>

View File

@ -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">

View File

@ -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>
);
}

View File

@ -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

View File

@ -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>
);
}

View File

@ -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">

View 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,
);
}

View File

@ -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>
);
}

View File

@ -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;

View 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
View 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";
}

View 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;
}
}