import { timestampDate } from "@bufbuild/protobuf/wkt"; import dayjs from "dayjs"; import { ExternalLinkIcon, PaperclipIcon, SearchIcon, Trash } from "lucide-react"; import { observer } from "mobx-react-lite"; import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "react-hot-toast"; import { Link } from "react-router-dom"; import AttachmentIcon from "@/components/AttachmentIcon"; import ConfirmDialog from "@/components/ConfirmDialog"; import Empty from "@/components/Empty"; import MobileHeader from "@/components/MobileHeader"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Separator } from "@/components/ui/separator"; import { attachmentServiceClient } from "@/connect"; import useDialog from "@/hooks/useDialog"; import useLoading from "@/hooks/useLoading"; import useResponsiveWidth from "@/hooks/useResponsiveWidth"; import i18n from "@/i18n"; import { attachmentStore } from "@/store"; import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; import { useTranslate } from "@/utils/i18n"; const PAGE_SIZE = 50; const groupAttachmentsByDate = (attachments: Attachment[]): Map => { const grouped = new Map(); const sorted = [...attachments].sort((a, b) => { const aTime = a.createTime ? timestampDate(a.createTime) : undefined; const bTime = b.createTime ? timestampDate(b.createTime) : undefined; return dayjs(bTime).unix() - dayjs(aTime).unix(); }); for (const attachment of sorted) { const createTime = attachment.createTime ? timestampDate(attachment.createTime) : undefined; const monthKey = dayjs(createTime).format("YYYY-MM"); const group = grouped.get(monthKey) ?? []; group.push(attachment); grouped.set(monthKey, group); } return grouped; }; const filterAttachments = (attachments: Attachment[], searchQuery: string): Attachment[] => { if (!searchQuery.trim()) return attachments; const query = searchQuery.toLowerCase(); return attachments.filter((attachment) => attachment.filename.toLowerCase().includes(query)); }; interface AttachmentItemProps { attachment: Attachment; } const AttachmentItem = ({ attachment }: AttachmentItemProps) => (

{attachment.filename}

{attachment.memo && ( )}
); const Attachments = observer(() => { const t = useTranslate(); const { md } = useResponsiveWidth(); const loadingState = useLoading(); const deleteUnusedAttachmentsDialog = useDialog(); const [searchQuery, setSearchQuery] = useState(""); const [attachments, setAttachments] = useState([]); const [nextPageToken, setNextPageToken] = useState(""); const [isLoadingMore, setIsLoadingMore] = useState(false); // Memoized computed values const filteredAttachments = useMemo(() => filterAttachments(attachments, searchQuery), [attachments, searchQuery]); const usedAttachments = useMemo(() => filteredAttachments.filter((attachment) => attachment.memo), [filteredAttachments]); const unusedAttachments = useMemo(() => filteredAttachments.filter((attachment) => !attachment.memo), [filteredAttachments]); const groupedAttachments = useMemo(() => groupAttachmentsByDate(usedAttachments), [usedAttachments]); // Fetch initial attachments useEffect(() => { const fetchInitialAttachments = async () => { try { const { attachments: fetchedAttachments, nextPageToken } = await attachmentServiceClient.listAttachments({ pageSize: PAGE_SIZE, }); setAttachments(fetchedAttachments); setNextPageToken(nextPageToken ?? ""); } catch (error) { console.error("Failed to fetch attachments:", error); toast.error("Failed to load attachments. Please try again."); } finally { loadingState.setFinish(); } }; fetchInitialAttachments(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Load more attachments with pagination const handleLoadMore = useCallback(async () => { if (!nextPageToken || isLoadingMore) return; setIsLoadingMore(true); try { const { attachments: fetchedAttachments, nextPageToken: newPageToken } = await attachmentServiceClient.listAttachments({ pageSize: PAGE_SIZE, pageToken: nextPageToken, }); setAttachments((prev) => [...prev, ...fetchedAttachments]); setNextPageToken(newPageToken ?? ""); } catch (error) { console.error("Failed to load more attachments:", error); toast.error("Failed to load more attachments. Please try again."); } finally { setIsLoadingMore(false); } }, [nextPageToken, isLoadingMore]); // Refetch all attachments from the beginning const handleRefetch = useCallback(async () => { try { loadingState.setLoading(); const { attachments: fetchedAttachments, nextPageToken } = await attachmentServiceClient.listAttachments({ pageSize: PAGE_SIZE, }); setAttachments(fetchedAttachments); setNextPageToken(nextPageToken ?? ""); loadingState.setFinish(); } catch (error) { console.error("Failed to refetch attachments:", error); loadingState.setError(); toast.error("Failed to refresh attachments. Please try again."); } }, [loadingState]); // Delete all unused attachments const handleDeleteUnusedAttachments = useCallback(async () => { try { await Promise.all(unusedAttachments.map((attachment) => attachmentStore.deleteAttachment(attachment.name))); toast.success(t("resource.delete-all-unused-success")); } catch (error) { console.error("Failed to delete unused attachments:", error); toast.error(t("resource.delete-all-unused-error")); } finally { await handleRefetch(); } }, [unusedAttachments, t, handleRefetch]); // Handle search input change const handleSearchChange = useCallback((e: React.ChangeEvent) => { setSearchQuery(e.target.value); }, []); return (
{!md && }

{t("common.attachments")}

{loadingState.isLoading ? (

{t("resource.fetching-data")}

) : ( <> {filteredAttachments.length === 0 ? (

{t("message.no-data")}

) : ( <>
{Array.from(groupedAttachments.entries()).map(([monthStr, attachments]) => { return (
{dayjs(monthStr).year()} {dayjs(monthStr).toDate().toLocaleString(i18n.language, { month: "short" })}
{attachments.map((attachment) => ( ))}
); })} {unusedAttachments.length > 0 && ( <>
{t("resource.unused-resources")} ({unusedAttachments.length})
{unusedAttachments.map((attachment) => ( ))}
)}
{nextPageToken && (
)} )} )}
); }); export default Attachments;