160 lines
5.3 KiB
TypeScript
160 lines
5.3 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
|
|
import { useAppTranslation } from '@features/localization/renderer';
|
|
import { FileIcon } from '@renderer/components/team/editor/FileIcon';
|
|
import { useStore } from '@renderer/store';
|
|
import { isImageMime } from '@renderer/utils/attachmentUtils';
|
|
import { Loader2 } from 'lucide-react';
|
|
|
|
import { AttachmentThumbnail } from './AttachmentThumbnail';
|
|
import { ImageLightbox } from './ImageLightbox';
|
|
|
|
import type { AttachmentFileData, AttachmentMeta } from '@shared/types';
|
|
|
|
interface AttachmentDisplayProps {
|
|
teamName: string;
|
|
messageId: string;
|
|
attachments: AttachmentMeta[];
|
|
}
|
|
|
|
export const AttachmentDisplay = ({
|
|
teamName,
|
|
messageId,
|
|
attachments,
|
|
}: AttachmentDisplayProps): React.JSX.Element | null => {
|
|
const { t } = useAppTranslation('team');
|
|
const revealFileInEditor = useStore((s) => s.revealFileInEditor);
|
|
const [state, setState] = useState<{
|
|
loaded: AttachmentFileData[];
|
|
loading: boolean;
|
|
key: string;
|
|
}>({ loaded: [], loading: true, key: `${teamName}:${messageId}` });
|
|
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
|
|
|
const currentKey = `${teamName}:${messageId}`;
|
|
// Reset loading state when deps change (React 18+ pattern: derive from props)
|
|
if (state.key !== currentKey) {
|
|
setState({ loaded: [], loading: true, key: currentKey });
|
|
}
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
void window.electronAPI.teams
|
|
.getAttachments(teamName, messageId)
|
|
.then((data) => {
|
|
if (!cancelled) setState({ loaded: data, loading: false, key: `${teamName}:${messageId}` });
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) setState((prev) => ({ ...prev, loading: false }));
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [teamName, messageId]);
|
|
|
|
const { loaded, loading } = state;
|
|
|
|
if (attachments.length === 0) return null;
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center gap-1.5 py-1 text-[11px] text-[var(--color-text-muted)]">
|
|
<Loader2 size={14} className="animate-spin" />
|
|
{t('taskAttachments.loading')}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Build lookup for loaded data
|
|
const dataById = new Map(loaded.map((d) => [d.id, d]));
|
|
|
|
const items = attachments
|
|
.map((meta) => {
|
|
const data = dataById.get(meta.id);
|
|
if (!data) return null;
|
|
const isImage = isImageMime(data.mimeType);
|
|
return {
|
|
meta,
|
|
dataUrl: isImage ? `data:${data.mimeType};base64,${data.data}` : undefined,
|
|
filePath: data.filePath ?? meta.filePath,
|
|
isImage,
|
|
};
|
|
})
|
|
.filter(Boolean) as {
|
|
meta: AttachmentMeta;
|
|
dataUrl: string | undefined;
|
|
filePath: string | undefined;
|
|
isImage: boolean;
|
|
}[];
|
|
|
|
if (items.length === 0) return null;
|
|
|
|
// Build lightbox slides for images only, with visual→lightbox index mapping
|
|
const imageSlides: { src: string; alt: string }[] = [];
|
|
const visualToLightbox = new Map<number, number>();
|
|
items.forEach((item, i) => {
|
|
if (item.isImage && item.dataUrl) {
|
|
visualToLightbox.set(i, imageSlides.length);
|
|
imageSlides.push({ src: item.dataUrl, alt: item.meta.filename });
|
|
}
|
|
});
|
|
|
|
return (
|
|
<>
|
|
<div className="mt-1.5 flex flex-wrap gap-2">
|
|
{items.map((item, i) =>
|
|
item.isImage && item.dataUrl ? (
|
|
<AttachmentThumbnail
|
|
key={item.meta.id}
|
|
src={item.dataUrl}
|
|
alt={item.meta.filename}
|
|
size="md"
|
|
onClick={
|
|
visualToLightbox.has(i)
|
|
? () => setLightboxIndex(visualToLightbox.get(i)!)
|
|
: undefined
|
|
}
|
|
/>
|
|
) : item.filePath ? (
|
|
<button
|
|
key={item.meta.id}
|
|
type="button"
|
|
className="flex size-20 flex-col items-center justify-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] transition-colors hover:border-[var(--color-border-emphasis)] hover:bg-[var(--color-surface)]"
|
|
title={item.meta.filename}
|
|
aria-label={`Open ${item.meta.filename}`}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
revealFileInEditor(item.filePath!);
|
|
}}
|
|
onKeyDown={(event) => event.stopPropagation()}
|
|
>
|
|
<FileIcon fileName={item.meta.filename} className="size-5" />
|
|
<span className="max-w-[72px] truncate text-[9px] text-[var(--color-text-muted)]">
|
|
{item.meta.filename}
|
|
</span>
|
|
</button>
|
|
) : (
|
|
<div
|
|
key={item.meta.id}
|
|
className="flex size-20 flex-col items-center justify-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)]"
|
|
title={item.meta.filename}
|
|
>
|
|
<FileIcon fileName={item.meta.filename} className="size-5" />
|
|
<span className="max-w-[72px] truncate text-[9px] text-[var(--color-text-muted)]">
|
|
{item.meta.filename}
|
|
</span>
|
|
</div>
|
|
)
|
|
)}
|
|
</div>
|
|
{lightboxIndex !== null && imageSlides[lightboxIndex] ? (
|
|
<ImageLightbox
|
|
open
|
|
onClose={() => setLightboxIndex(null)}
|
|
slides={imageSlides}
|
|
index={lightboxIndex}
|
|
/>
|
|
) : null}
|
|
</>
|
|
);
|
|
};
|