Corp/frontend/src/components/tasks/TaskCommentAttachment.tsx

163 lines
6.0 KiB
TypeScript

"use client";
import Image from "next/image";
import { Download, Eye, FileText, Loader2, Play } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
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 formatFileSize(size: number): string {
if (size < 1024) return `${size} Б`;
if (size < 1024 * 1024) return `${Math.ceil(size / 1024)} КБ`;
return `${(size / (1024 * 1024)).toLocaleString("ru-RU", {
maximumFractionDigits: 1,
})} МБ`;
}
export default function TaskCommentAttachment({
taskId,
attachment,
onDownload,
}: {
taskId: number;
attachment: TaskAttachment;
onDownload: (attachment: TaskAttachment) => void | Promise<void>;
}) {
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);
const [preview, setPreview] = useState<MediaPreview | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const previewUrlRef = useRef<string | null>(null);
const { elementRef, isNearViewport } = useNearViewport();
useEffect(() => {
if (!media || !isNearViewport) return;
let active = true;
let objectUrl: string | null = null;
void apiClient.downloadTaskAttachmentThumbnail(taskId, attachment)
.then((blob) => {
if (!active) return;
objectUrl = URL.createObjectURL(blob);
setThumbnailSource(objectUrl);
})
.catch(() => {
if (active) setThumbnailFailed(true);
});
return () => {
active = false;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [attachment, isNearViewport, media, taskId]);
useEffect(() => () => {
if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current);
}, []);
const closePreview = useCallback(() => {
setPreview(null);
if (previewUrlRef.current) {
URL.revokeObjectURL(previewUrlRef.current);
previewUrlRef.current = null;
}
}, []);
const openPreview = useCallback(async () => {
if (!media || previewLoading) return;
setPreviewLoading(true);
try {
const { blob } = await apiClient.downloadTaskAttachment(taskId, attachment);
if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current);
const source = URL.createObjectURL(blob);
previewUrlRef.current = source;
setPreview({
type: video ? "video" : "image",
src: source,
name: attachment.file_name,
});
} finally {
setPreviewLoading(false);
}
}, [attachment, media, previewLoading, taskId, video]);
if (media) {
return (
<div ref={elementRef} className="app-surface-elevated overflow-hidden rounded-lg border border-[var(--border-subtle)]">
<button
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)] outline-none focus-visible:ring-2 focus-visible:ring-sky-400/70 disabled:cursor-default"
title={`Просмотреть ${attachment.file_name}`}
>
{thumbnailSource ? (
<Image
src={thumbnailSource}
alt={attachment.file_name}
fill
unoptimized
sizes="(max-width: 640px) 100vw, 320px"
className="object-cover"
/>
) : (
<span className="app-text-muted absolute inset-0 flex items-center justify-center">
{thumbnailFailed ? <FileText size={20} /> : <Loader2 size={18} className="animate-spin" />}
</span>
)}
{thumbnailSource ? (
<span className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/35 text-white opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100">
{previewLoading ? <Loader2 size={22} className="animate-spin" /> : video ? <Play size={24} fill="currentColor" /> : <Eye size={24} />}
</span>
) : null}
</button>
<button
type="button"
onClick={() => void onDownload(attachment)}
className="flex w-full min-w-0 items-center gap-2 border-t border-[var(--border-subtle)] px-2.5 py-2 text-left"
title={`Скачать ${attachment.file_name}`}
>
<span className="min-w-0 flex-1 truncate text-xs font-medium text-[var(--foreground)]">
{attachment.file_name}
</span>
<Download size={13} className="app-text-muted shrink-0" />
</button>
{preview ? <MediaPreviewModal preview={preview} onClose={closePreview} /> : null}
</div>
);
}
return (
<button
type="button"
onClick={() => void onDownload(attachment)}
className="app-surface-elevated flex min-w-0 items-center gap-2 rounded-lg border border-[var(--border-subtle)] px-2.5 py-2 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-xs font-medium text-[var(--foreground)]">
{attachment.file_name}
</span>
<span className="app-text-muted mt-0.5 block text-[10px]">
{formatFileSize(attachment.file_size)}
</span>
</span>
<Download size={13} className="app-text-muted shrink-0" />
</button>
);
}