163 lines
6.0 KiB
TypeScript
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>
|
|
);
|
|
}
|