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 (
+
+
})
+
+ );
+ }
+
+ 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.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}
+ />
);
};