diff --git a/web/src/components/AttachmentLibrary/AttachmentFileRows.tsx b/web/src/components/AttachmentLibrary/AttachmentFileRows.tsx new file mode 100644 index 000000000..cfa64d027 --- /dev/null +++ b/web/src/components/AttachmentLibrary/AttachmentFileRows.tsx @@ -0,0 +1,128 @@ +import { FileAudioIcon, FileIcon, PlayIcon } from "lucide-react"; +import AudioAttachmentItem from "@/components/MemoMetadata/Attachment/AudioAttachmentItem"; +import type { AttachmentLibraryListItem } from "@/hooks/useAttachmentLibrary"; +import { cn } from "@/lib/utils"; +import { getAttachmentThumbnailUrl, getAttachmentType, isMotionAttachment } from "@/utils/attachment"; +import { AttachmentMetadataLine, AttachmentOpenButton, AttachmentSourceChip } from "./AttachmentLibraryPrimitives"; + +const AttachmentThumb = ({ item, className }: { item: AttachmentLibraryListItem; className?: string }) => { + const type = getAttachmentType(item.attachment); + const isMotion = isMotionAttachment(item.attachment); + + if (type === "image/*" || isMotion) { + return ( +
+ {item.attachment.filename} +
+ ); + } + + if (type === "video/*") { + return ( +
+
+ ); + } + + return ( +
+ {type === "audio/*" ? : } +
+ ); +}; + +export const AttachmentDocumentRows = ({ items }: { items: AttachmentLibraryListItem[] }) => { + return ( +
+ {items.map((item) => ( +
+
+ +
+ +
+
+ {item.attachment.filename} +
+
+ + +
+
+ + +
+ ))} +
+ ); +}; + +export const AttachmentAudioRows = ({ items }: { items: AttachmentLibraryListItem[] }) => { + return ( +
+ {items.map((item) => ( +
+ +
+
+ + +
+ +
+
+ ))} +
+ ); +}; + +export const AttachmentUnusedRows = ({ items }: { items: AttachmentLibraryListItem[] }) => { + return ( +
+ {items.map((item) => ( +
+ + +
+
+ {item.attachment.filename} +
+
+ + +
+
+ + +
+ ))} +
+ ); +}; diff --git a/web/src/components/AttachmentLibrary/AttachmentLibraryEmptyState.tsx b/web/src/components/AttachmentLibrary/AttachmentLibraryEmptyState.tsx new file mode 100644 index 000000000..dd5eb2a82 --- /dev/null +++ b/web/src/components/AttachmentLibrary/AttachmentLibraryEmptyState.tsx @@ -0,0 +1,57 @@ +import { FileAudioIcon, FileStackIcon, ImageIcon } from "lucide-react"; +import type { ComponentType } from "react"; +import type { AttachmentLibraryTab } from "@/hooks/useAttachmentLibrary"; +import { cn } from "@/lib/utils"; +import { useTranslate } from "@/utils/i18n"; + +interface AttachmentLibraryEmptyStateProps { + className?: string; + tab: AttachmentLibraryTab; +} + +const EMPTY_STATE_CONFIG: Record< + AttachmentLibraryTab, + { + descriptionKey: "attachment-library.empty.audio" | "attachment-library.empty.documents" | "attachment-library.empty.media"; + icon: ComponentType<{ className?: string }>; + titleKey: "attachment-library.tabs.audio" | "attachment-library.tabs.documents" | "attachment-library.tabs.media"; + } +> = { + audio: { + descriptionKey: "attachment-library.empty.audio", + icon: FileAudioIcon, + titleKey: "attachment-library.tabs.audio", + }, + documents: { + descriptionKey: "attachment-library.empty.documents", + icon: FileStackIcon, + titleKey: "attachment-library.tabs.documents", + }, + media: { + descriptionKey: "attachment-library.empty.media", + icon: ImageIcon, + titleKey: "attachment-library.tabs.media", + }, +}; + +const AttachmentLibraryEmptyState = ({ className, tab }: AttachmentLibraryEmptyStateProps) => { + const t = useTranslate(); + const { descriptionKey, icon: Icon, titleKey } = EMPTY_STATE_CONFIG[tab]; + + return ( +
+
+ +
+
{t(titleKey)}
+

{t(descriptionKey)}

+
+ ); +}; + +export default AttachmentLibraryEmptyState; diff --git a/web/src/components/AttachmentLibrary/AttachmentLibraryPrimitives.tsx b/web/src/components/AttachmentLibrary/AttachmentLibraryPrimitives.tsx new file mode 100644 index 000000000..d173e8983 --- /dev/null +++ b/web/src/components/AttachmentLibrary/AttachmentLibraryPrimitives.tsx @@ -0,0 +1,90 @@ +import { ExternalLinkIcon } from "lucide-react"; +import { Link } from "react-router-dom"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { useTranslate } from "@/utils/i18n"; + +interface AttachmentMetadataLineProps { + className?: string; + items: Array; +} + +interface AttachmentSourceChipProps { + memoName?: string; + unlinkedLabelKey?: "attachment-library.labels.not-linked" | "attachment-library.labels.unused"; +} + +interface AttachmentOpenButtonProps { + className?: string; + href: string; +} + +export const AttachmentMetadataLine = ({ className, items }: AttachmentMetadataLineProps) => { + const visibleItems = items.filter((item): item is string => Boolean(item)); + + if (visibleItems.length === 0) { + return null; + } + + return ( +
+ {visibleItems.map((item, index) => ( + + {index > 0 && } + {item} + + ))} +
+ ); +}; + +export const AttachmentSourceChip = ({ + memoName, + unlinkedLabelKey = "attachment-library.labels.not-linked", +}: AttachmentSourceChipProps) => { + const t = useTranslate(); + + if (!memoName) { + return ( + + {t(unlinkedLabelKey)} + + ); + } + + return ( + + {t("attachment-library.labels.memo")} + + ); +}; + +export const AttachmentOpenButton = ({ className, href }: AttachmentOpenButtonProps) => { + const t = useTranslate(); + + return ( + + ); +}; diff --git a/web/src/components/AttachmentLibrary/AttachmentLibraryStates.tsx b/web/src/components/AttachmentLibrary/AttachmentLibraryStates.tsx new file mode 100644 index 000000000..cc09ce8c1 --- /dev/null +++ b/web/src/components/AttachmentLibrary/AttachmentLibraryStates.tsx @@ -0,0 +1,77 @@ +import { LoaderCircleIcon } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useTranslate } from "@/utils/i18n"; + +interface AttachmentLibraryErrorStateProps { + error?: Error; + onRetry: () => void; +} + +interface AttachmentLibrarySkeletonGridProps { + count?: number; +} + +interface AttachmentLibraryUnusedPanelProps { + count: number; + isDeleting: boolean; + isExpanded: boolean; + onDelete: () => void; + onToggle: () => void; +} + +export const AttachmentLibrarySkeletonGrid = ({ count = 8 }: AttachmentLibrarySkeletonGridProps) => { + return ( +
+ {Array.from({ length: count }).map((_, index) => ( +
+
+
+
+
+
+
+
+ ))} +
+ ); +}; + +export const AttachmentLibraryErrorState = ({ error, onRetry }: AttachmentLibraryErrorStateProps) => { + const t = useTranslate(); + + return ( +
+

{error?.message ?? t("attachment-library.errors.load")}

+ +
+ ); +}; + +export const AttachmentLibraryUnusedPanel = ({ count, isDeleting, isExpanded, onDelete, onToggle }: AttachmentLibraryUnusedPanelProps) => { + const t = useTranslate(); + + return ( +
+
+
+
+ {t("attachment-library.unused.title")} ({count}) +
+

{t("attachment-library.unused.description")}

+
+ +
+ + +
+
+
+ ); +}; diff --git a/web/src/components/AttachmentLibrary/AttachmentLibraryToolbar.tsx b/web/src/components/AttachmentLibrary/AttachmentLibraryToolbar.tsx new file mode 100644 index 000000000..b5c0539b9 --- /dev/null +++ b/web/src/components/AttachmentLibrary/AttachmentLibraryToolbar.tsx @@ -0,0 +1,64 @@ +import { FileAudioIcon, FileStackIcon, ImageIcon } from "lucide-react"; +import type { ComponentType } from "react"; +import { Button } from "@/components/ui/button"; +import type { AttachmentLibraryStats, AttachmentLibraryTab } from "@/hooks/useAttachmentLibrary"; +import { cn } from "@/lib/utils"; +import { useTranslate } from "@/utils/i18n"; + +interface AttachmentLibraryToolbarProps { + activeTab: AttachmentLibraryTab; + onTabChange: (tab: AttachmentLibraryTab) => void; + stats: AttachmentLibraryStats; +} + +const TAB_CONFIG: Array<{ + key: AttachmentLibraryTab; + labelKey: "media" | "documents" | "audio"; + icon: ComponentType<{ className?: string }>; + count: (stats: AttachmentLibraryStats) => number; +}> = [ + { key: "media", labelKey: "media", icon: ImageIcon, count: (stats) => stats.media }, + { key: "audio", labelKey: "audio", icon: FileAudioIcon, count: (stats) => stats.audio }, + { key: "documents", labelKey: "documents", icon: FileStackIcon, count: (stats) => stats.documents }, +]; + +const AttachmentLibraryToolbar = ({ activeTab, onTabChange, stats }: AttachmentLibraryToolbarProps) => { + const t = useTranslate(); + + return ( +
+
+ {TAB_CONFIG.map((tab) => { + const Icon = tab.icon; + const isActive = activeTab === tab.key; + + return ( + + ); + })} +
+
+ ); +}; + +export default AttachmentLibraryToolbar; diff --git a/web/src/components/AttachmentLibrary/AttachmentMediaGrid.tsx b/web/src/components/AttachmentLibrary/AttachmentMediaGrid.tsx new file mode 100644 index 000000000..916dd78d0 --- /dev/null +++ b/web/src/components/AttachmentLibrary/AttachmentMediaGrid.tsx @@ -0,0 +1,91 @@ +import { PlayIcon } from "lucide-react"; +import MotionPhotoPreview from "@/components/MotionPhotoPreview"; +import { Badge } from "@/components/ui/badge"; +import type { AttachmentLibraryMediaItem, AttachmentLibraryMonthGroup } from "@/hooks/useAttachmentLibrary"; +import { useTranslate } from "@/utils/i18n"; +import { AttachmentMetadataLine, AttachmentOpenButton } from "./AttachmentLibraryPrimitives"; + +interface AttachmentMediaGridProps { + groups: AttachmentLibraryMonthGroup[]; + onPreview: (itemId: string) => void; +} + +const AttachmentMediaCard = ({ item, onPreview }: { item: AttachmentLibraryMediaItem; onPreview: () => void }) => { + const t = useTranslate(); + + return ( +
+ + +
+
+
+ {item.filename} +
+ + {item.kind === "motion" && ( + + {t("attachment-library.labels.live")} + + )} +
+ +
+ + + +
+
+
+ ); +}; + +const AttachmentMediaGrid = ({ groups, onPreview }: AttachmentMediaGridProps) => { + return ( +
+ {groups.map((group) => ( +
+
+
{group.label}
+
+
+ +
+ {group.items.map((item) => ( + onPreview(item.previewItem.id)} /> + ))} +
+
+ ))} +
+ ); +}; + +export default AttachmentMediaGrid; diff --git a/web/src/components/AttachmentLibrary/index.ts b/web/src/components/AttachmentLibrary/index.ts new file mode 100644 index 000000000..25fa00348 --- /dev/null +++ b/web/src/components/AttachmentLibrary/index.ts @@ -0,0 +1,6 @@ +export { AttachmentAudioRows, AttachmentDocumentRows, AttachmentUnusedRows } from "./AttachmentFileRows"; +export { default as AttachmentLibraryEmptyState } from "./AttachmentLibraryEmptyState"; +export { AttachmentMetadataLine, AttachmentOpenButton, AttachmentSourceChip } from "./AttachmentLibraryPrimitives"; +export { AttachmentLibraryErrorState, AttachmentLibrarySkeletonGrid, AttachmentLibraryUnusedPanel } from "./AttachmentLibraryStates"; +export { default as AttachmentLibraryToolbar } from "./AttachmentLibraryToolbar"; +export { default as AttachmentMediaGrid } from "./AttachmentMediaGrid"; diff --git a/web/src/hooks/useAttachmentLibrary.ts b/web/src/hooks/useAttachmentLibrary.ts new file mode 100644 index 000000000..e475b93b2 --- /dev/null +++ b/web/src/hooks/useAttachmentLibrary.ts @@ -0,0 +1,203 @@ +import { timestampDate } from "@bufbuild/protobuf/wkt"; +import dayjs from "dayjs"; +import { useMemo } from "react"; +import { + getAttachmentMetadata, + isAudioAttachment, + isImageAttachment, + isVideoAttachment, +} from "@/components/MemoMetadata/Attachment/attachmentHelpers"; +import { useInfiniteAttachments } from "@/hooks/useAttachmentQueries"; +import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; +import { isMotionAttachment } from "@/utils/attachment"; +import { useTranslate } from "@/utils/i18n"; +import { type AttachmentVisualItem, buildAttachmentVisualItems } from "@/utils/media-item"; + +export type AttachmentLibraryTab = "media" | "documents" | "audio"; + +export interface AttachmentLibraryStats { + unused: number; + media: number; + documents: number; + audio: number; +} + +export interface AttachmentLibraryListItem { + attachment: Attachment; + createdAt?: Date; + createdLabel: string; + fileTypeLabel: string; + fileSizeLabel?: string; + memoName?: string; + sourceUrl: string; +} + +export interface AttachmentLibraryMediaItem extends AttachmentVisualItem { + primaryAttachment: Attachment; + createdAt?: Date; + createdLabel: string; + fileTypeLabel: string; +} + +export interface AttachmentLibraryMonthGroup { + key: string; + label: string; + items: AttachmentLibraryMediaItem[]; +} + +const PAGE_SIZE = 50; + +const sortByNewest = (a?: Date, b?: Date) => (b?.getTime() ?? 0) - (a?.getTime() ?? 0); + +const isLinkedAttachment = (attachment: Attachment) => Boolean(attachment.memo); + +const isVisualAttachment = (attachment: Attachment) => + isImageAttachment(attachment) || isVideoAttachment(attachment) || isMotionAttachment(attachment); + +const toCreatedAt = (attachment: Attachment): Date | undefined => { + return attachment.createTime ? timestampDate(attachment.createTime) : undefined; +}; + +const formatCreatedAt = (date: Date | undefined, locale: string) => { + if (!date) { + return "—"; + } + + return date.toLocaleDateString(locale, { + month: "short", + day: "numeric", + year: "numeric", + }); +}; + +const toLibraryListItem = (attachment: Attachment, locale: string): AttachmentLibraryListItem => { + const createdAt = toCreatedAt(attachment); + const { fileTypeLabel, fileSizeLabel } = getAttachmentMetadata(attachment); + + return { + attachment, + createdAt, + createdLabel: formatCreatedAt(createdAt, locale), + fileTypeLabel, + fileSizeLabel, + memoName: attachment.memo, + sourceUrl: attachment.externalLink || `${window.location.origin}/file/${attachment.name}/${attachment.filename}`, + }; +}; + +const toLibraryMediaItem = (item: AttachmentVisualItem, locale: string, livePhotoLabel: string): AttachmentLibraryMediaItem => { + const primaryAttachment = item.attachments[0]; + const createdAt = toCreatedAt(primaryAttachment); + const { fileTypeLabel } = getAttachmentMetadata(primaryAttachment); + + return { + ...item, + primaryAttachment, + createdAt, + createdLabel: formatCreatedAt(createdAt, locale), + fileTypeLabel: item.kind === "motion" ? livePhotoLabel : fileTypeLabel, + }; +}; + +const groupMediaByMonth = ( + items: AttachmentLibraryMediaItem[], + locale: string, + unknownDateLabel: string, +): AttachmentLibraryMonthGroup[] => { + const groups = new Map(); + + for (const item of items) { + const key = item.createdAt ? dayjs(item.createdAt).format("YYYY-MM") : "unknown"; + const group = groups.get(key) ?? []; + group.push(item); + groups.set(key, group); + } + + return Array.from(groups.entries()) + .sort(([a], [b]) => (a === "unknown" ? 1 : b === "unknown" ? -1 : b.localeCompare(a))) + .map(([key, groupedItems]) => ({ + key, + label: + key === "unknown" + ? unknownDateLabel + : dayjs(`${key}-01`).toDate().toLocaleDateString(locale, { + month: "short", + year: "numeric", + }), + items: groupedItems.sort((a, b) => sortByNewest(a.createdAt, b.createdAt)), + })); +}; + +export function useAttachmentLibrary(locale: string) { + const t = useTranslate(); + const query = useInfiniteAttachments({ + pageSize: PAGE_SIZE, + orderBy: "create_time desc", + }); + + const attachments = useMemo(() => (query.data?.pages ?? []).flatMap((page) => page.attachments), [query.data?.pages]); + + const linkedAttachments = useMemo( + () => attachments.filter(isLinkedAttachment).sort((a, b) => sortByNewest(toCreatedAt(a), toCreatedAt(b))), + [attachments], + ); + + const unusedAttachments = useMemo( + () => attachments.filter((attachment) => !isLinkedAttachment(attachment)).sort((a, b) => sortByNewest(toCreatedAt(a), toCreatedAt(b))), + [attachments], + ); + + const mediaItems = useMemo( + () => + buildAttachmentVisualItems(linkedAttachments.filter(isVisualAttachment)) + .map((item) => toLibraryMediaItem(item, locale, t("attachment-library.labels.live-photo"))) + .sort((a, b) => sortByNewest(a.createdAt, b.createdAt)), + [linkedAttachments, locale, t], + ); + + const documentItems = useMemo( + () => + linkedAttachments + .filter((attachment) => !isVisualAttachment(attachment) && !isAudioAttachment(attachment)) + .map((attachment) => toLibraryListItem(attachment, locale)), + [linkedAttachments, locale], + ); + + const audioItems = useMemo( + () => linkedAttachments.filter(isAudioAttachment).map((attachment) => toLibraryListItem(attachment, locale)), + [linkedAttachments, locale], + ); + + const unusedItems = useMemo( + () => unusedAttachments.map((attachment) => toLibraryListItem(attachment, locale)), + [unusedAttachments, locale], + ); + + const mediaGroups = useMemo( + () => groupMediaByMonth(mediaItems, locale, t("attachment-library.labels.unknown-date")), + [locale, mediaItems, t], + ); + const mediaPreviewItems = useMemo(() => mediaItems.map((item) => item.previewItem), [mediaItems]); + + const stats = useMemo( + () => ({ + unused: unusedAttachments.length, + media: mediaItems.length, + documents: documentItems.length, + audio: audioItems.length, + }), + [audioItems.length, documentItems.length, mediaItems.length, unusedAttachments.length], + ); + + return { + ...query, + attachments, + mediaGroups, + mediaItems, + mediaPreviewItems, + documentItems, + audioItems, + unusedItems, + stats, + }; +} diff --git a/web/src/hooks/useAttachmentQueries.ts b/web/src/hooks/useAttachmentQueries.ts index 564c92947..756a6f4c2 100644 --- a/web/src/hooks/useAttachmentQueries.ts +++ b/web/src/hooks/useAttachmentQueries.ts @@ -1,6 +1,12 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { create } from "@bufbuild/protobuf"; +import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { attachmentServiceClient } from "@/connect"; -import type { Attachment, ListAttachmentsRequest } from "@/types/proto/api/v1/attachment_service_pb"; +import { + type Attachment, + BatchDeleteAttachmentsRequestSchema, + type ListAttachmentsRequest, + ListAttachmentsRequestSchema, +} from "@/types/proto/api/v1/attachment_service_pb"; // Query keys factory export const attachmentKeys = { @@ -16,12 +22,32 @@ export function useAttachments() { return useQuery({ queryKey: attachmentKeys.lists(), queryFn: async () => { - const { attachments } = await attachmentServiceClient.listAttachments({}); + const { attachments } = await attachmentServiceClient.listAttachments(create(ListAttachmentsRequestSchema, {})); return attachments; }, }); } +export function useInfiniteAttachments(request: Partial = {}, options?: { enabled?: boolean }) { + return useInfiniteQuery({ + queryKey: attachmentKeys.list(request), + queryFn: async ({ pageParam }) => { + const response = await attachmentServiceClient.listAttachments( + create(ListAttachmentsRequestSchema, { + ...request, + pageToken: pageParam || "", + } as Record), + ); + return response; + }, + initialPageParam: "", + getNextPageParam: (lastPage) => lastPage.nextPageToken || undefined, + staleTime: 1000 * 60, + gcTime: 1000 * 60 * 5, + enabled: options?.enabled ?? true, + }); +} + // Hook to create/upload attachment export function useCreateAttachment() { const queryClient = useQueryClient(); @@ -55,3 +81,20 @@ export function useDeleteAttachment() { }, }); } + +export function useBatchDeleteAttachments() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (names: string[]) => { + await attachmentServiceClient.batchDeleteAttachments(create(BatchDeleteAttachmentsRequestSchema, { names })); + return names; + }, + onSuccess: (names) => { + for (const name of names) { + queryClient.removeQueries({ queryKey: attachmentKeys.detail(name) }); + } + queryClient.invalidateQueries({ queryKey: attachmentKeys.lists() }); + }, + }); +} diff --git a/web/src/locales/en.json b/web/src/locales/en.json index a2569f42f..34686cebc 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -3,6 +3,7 @@ "blogs": "Blogs", "description": "A privacy-first, lightweight note-taking service. Easily capture and share your great thoughts.", "documents": "Documents", + "media": "Media", "github-repository": "GitHub Repo", "official-website": "Official Website" }, @@ -15,6 +16,38 @@ "sign-in-tip": "Already have an account?", "sign-up-tip": "Don't have an account yet?" }, + "attachment-library": { + "actions": { + "open": "Open", + "retry": "Retry" + }, + "empty": { + "audio": "No audio attachments yet.", + "documents": "No document attachments yet.", + "media": "No media attachments yet." + }, + "errors": { + "load": "Failed to load attachments." + }, + "labels": { + "live": "Live", + "live-photo": "Live Photo", + "memo": "Memo", + "not-linked": "Not linked", + "unknown-date": "Unknown date", + "unused": "Unused" + }, + "tabs": { + "audio": "Audio", + "documents": "Documents", + "media": "Media" + }, + "unused": { + "confirm-description": "This removes every uploaded file that is not linked to a memo.", + "description": "These uploads were never attached to a memo. Review or remove them here.", + "title": "Unlinked uploads" + } + }, "common": { "about": "About", "add": "Add", diff --git a/web/src/pages/Attachments.tsx b/web/src/pages/Attachments.tsx index ae4f37af2..1063efdf0 100644 --- a/web/src/pages/Attachments.tsx +++ b/web/src/pages/Attachments.tsx @@ -1,290 +1,219 @@ -import { timestampDate } from "@bufbuild/protobuf/wkt"; -import dayjs from "dayjs"; -import { ExternalLinkIcon, PaperclipIcon, SearchIcon, Trash } from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { create } from "@bufbuild/protobuf"; +import { LoaderCircleIcon } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; import { toast } from "react-hot-toast"; -import { Link } from "react-router-dom"; -import AttachmentIcon from "@/components/AttachmentIcon"; +import { + AttachmentAudioRows, + AttachmentDocumentRows, + AttachmentLibraryEmptyState, + AttachmentLibraryErrorState, + AttachmentLibrarySkeletonGrid, + AttachmentLibraryToolbar, + AttachmentLibraryUnusedPanel, + AttachmentMediaGrid, + AttachmentUnusedRows, +} from "@/components/AttachmentLibrary"; import ConfirmDialog from "@/components/ConfirmDialog"; -import Empty from "@/components/Empty"; import MobileHeader from "@/components/MobileHeader"; +import PreviewImageDialog from "@/components/PreviewImageDialog"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Separator } from "@/components/ui/separator"; import { attachmentServiceClient } from "@/connect"; -import { useDeleteAttachment } from "@/hooks/useAttachmentQueries"; +import { type AttachmentLibraryStats, type AttachmentLibraryTab, useAttachmentLibrary } from "@/hooks/useAttachmentLibrary"; +import { useBatchDeleteAttachments } from "@/hooks/useAttachmentQueries"; import useDialog from "@/hooks/useDialog"; -import useLoading from "@/hooks/useLoading"; import useMediaQuery from "@/hooks/useMediaQuery"; import i18n from "@/i18n"; import { handleError } from "@/lib/error"; -import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; +import { ListAttachmentsRequestSchema } from "@/types/proto/api/v1/attachment_service_pb"; import { useTranslate } from "@/utils/i18n"; -const PAGE_SIZE = 50; +const UNUSED_PAGE_SIZE = 1000; +const BATCH_DELETE_SIZE = 100; -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(); - }); +const TAB_COUNT_SELECTOR = { + audio: (stats: AttachmentLibraryStats) => stats.audio, + documents: (stats: AttachmentLibraryStats) => stats.documents, + media: (stats: AttachmentLibraryStats) => stats.media, +} as const; - 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); +const chunkNames = (names: string[], size: number) => { + const chunks: string[][] = []; + + for (let index = 0; index < names.length; index += size) { + chunks.push(names.slice(index, index + size)); } - return grouped; + return chunks; }; -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)); +const listUnusedAttachmentNames = async () => { + const names: string[] = []; + let pageToken = ""; + + do { + const response = await attachmentServiceClient.listAttachments( + create(ListAttachmentsRequestSchema, { + filter: "memo_id == null", + pageSize: UNUSED_PAGE_SIZE, + pageToken, + }), + ); + + names.push(...response.attachments.map((attachment) => attachment.name)); + pageToken = response.nextPageToken; + } while (pageToken); + + return names; }; -interface AttachmentItemProps { - attachment: Attachment; -} - -const AttachmentItem = ({ attachment }: AttachmentItemProps) => ( -
-
- -
-
-

{attachment.filename}

- {attachment.memo && ( - - - - )} -
-
-); - const Attachments = () => { const t = useTranslate(); const md = useMediaQuery("md"); - const loadingState = useLoading(); const deleteUnusedAttachmentsDialog = useDialog(); - const { mutateAsync: deleteAttachment } = useDeleteAttachment(); + const [activeTab, setActiveTab] = useState("media"); + const [previewState, setPreviewState] = useState({ open: false, initialIndex: 0 }); + const [showUnusedSection, setShowUnusedSection] = useState(false); + const { mutateAsync: batchDeleteAttachments, isPending: isDeletingUnused } = useBatchDeleteAttachments(); + const { + audioItems, + documentItems, + error, + fetchNextPage, + hasNextPage, + isError, + isFetching, + isFetchingNextPage, + isLoading, + mediaGroups, + mediaPreviewItems, + refetch, + stats, + unusedItems, + } = useAttachmentLibrary(i18n.language); - const [searchQuery, setSearchQuery] = useState(""); - const [attachments, setAttachments] = useState([]); - const [nextPageToken, setNextPageToken] = useState(""); - const [isLoadingMore, setIsLoadingMore] = useState(false); + const currentItemsCount = useMemo(() => TAB_COUNT_SELECTOR[activeTab](stats), [activeTab, stats]); - // 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) { - handleError(error, toast.error, { - context: "Failed to fetch attachments", - fallbackMessage: "Failed to load attachments. Please try again.", - }); - } finally { - loadingState.setFinish(); + if (stats.unused === 0) { + setShowUnusedSection(false); + } + }, [stats.unused]); + + const handlePreview = (itemId: string) => { + const initialIndex = mediaPreviewItems.findIndex((item) => item.id === itemId); + setPreviewState({ open: true, initialIndex: initialIndex >= 0 ? initialIndex : 0 }); + }; + + const handleDeleteUnusedAttachments = async () => { + try { + const names = await listUnusedAttachmentNames(); + + if (names.length === 0) { + await refetch(); + return; } - }; - fetchInitialAttachments(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + for (const chunk of chunkNames(names, BATCH_DELETE_SIZE)) { + await batchDeleteAttachments(chunk); + } - // 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) { - handleError(error, toast.error, { - context: "Failed to load more attachments", - fallbackMessage: "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) { - handleError(error, toast.error, { - context: "Failed to refetch attachments", - fallbackMessage: "Failed to refresh attachments. Please try again.", - onError: () => loadingState.setError(), - }); - } - }, [loadingState]); - - // Delete all unused attachments - const handleDeleteUnusedAttachments = useCallback(async () => { - try { - let allUnusedAttachments: Attachment[] = []; - let nextPageToken = ""; - do { - const response = await attachmentServiceClient.listAttachments({ - pageSize: 1000, - pageToken: nextPageToken, - filter: "memo_id == null", - }); - allUnusedAttachments = [...allUnusedAttachments, ...response.attachments]; - nextPageToken = response.nextPageToken; - } while (nextPageToken); - - await Promise.all(allUnusedAttachments.map((attachment) => deleteAttachment(attachment.name))); toast.success(t("resource.delete-all-unused-success")); - } catch (error) { - handleError(error, toast.error, { + await refetch(); + } catch (deleteError) { + handleError(deleteError, toast.error, { context: "Failed to delete unused attachments", fallbackMessage: t("resource.delete-all-unused-error"), }); - } finally { - await handleRefetch(); } - }, [t, handleRefetch, deleteAttachment]); + }; - // Handle search input change - const handleSearchChange = useCallback((e: React.ChangeEvent) => { - setSearchQuery(e.target.value); - }, []); + const renderContent = () => { + if (isLoading) { + return ; + } + + if (isError) { + return refetch()} />; + } + + if (currentItemsCount === 0) { + return ; + } + + if (activeTab === "media") { + return ; + } + + if (activeTab === "documents") { + return ; + } + + if (activeTab === "audio") { + return ; + } + + return null; + }; 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 && ( -
- -
- )} - - )} - - )} -
+
+ + + {stats.unused > 0 && ( + deleteUnusedAttachmentsDialog.open()} + onToggle={() => setShowUnusedSection((state) => !state)} + /> + )} + +
+ {renderContent()} + + {hasNextPage && ( +
+ +
+ )} + + {!isLoading && isFetching && !isFetchingNextPage && ( +
{t("resource.fetching-data")}
+ )}
+ + {showUnusedSection && stats.unused > 0 && ( +
+
{t("attachment-library.unused.title")}
+ +
+ )}
+ + setPreviewState((prev) => ({ ...prev, open }))} + items={mediaPreviewItems} + initialIndex={previewState.initialIndex} + />
); };