refactor(attachments): simplify the attachment library

- split attachment page states and primitives into focused components
- unify card and list item presentation across media, audio, documents, and unused uploads
- move attachment paging and cleanup flows onto shared query and view-model hooks
This commit is contained in:
boojack 2026-04-06 17:39:58 +08:00
parent 7ac9989d43
commit 2cbc70762b
11 changed files with 960 additions and 239 deletions

View File

@ -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 (
<div className={cn("overflow-hidden rounded-xl bg-muted/35", className)}>
<img
src={getAttachmentThumbnailUrl(item.attachment)}
alt={item.attachment.filename}
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
/>
</div>
);
}
if (type === "video/*") {
return (
<div className={cn("relative overflow-hidden rounded-xl bg-muted/35", className)}>
<video src={item.sourceUrl} className="h-full w-full object-cover" preload="metadata" />
<span className="absolute bottom-2 right-2 inline-flex h-7 w-7 items-center justify-center rounded-full bg-background/85 text-foreground shadow-sm">
<PlayIcon className="h-3.5 w-3.5 fill-current" />
</span>
</div>
);
}
return (
<div className={cn("flex items-center justify-center rounded-xl bg-muted/45 text-muted-foreground", className)}>
{type === "audio/*" ? <FileAudioIcon className="h-5 w-5" /> : <FileIcon className="h-5 w-5" />}
</div>
);
};
export const AttachmentDocumentRows = ({ items }: { items: AttachmentLibraryListItem[] }) => {
return (
<div className="space-y-3">
{items.map((item) => (
<article
key={item.attachment.name}
className="flex items-center gap-2.5 rounded-[18px] border border-border/60 bg-background/90 p-3 shadow-sm shadow-black/[0.02]"
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-muted/45 text-muted-foreground">
<FileIcon className="h-4.5 w-4.5" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground" title={item.attachment.filename}>
{item.attachment.filename}
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-1.5">
<AttachmentMetadataLine className="min-w-0 max-w-full" items={[item.fileTypeLabel, item.fileSizeLabel, item.createdLabel]} />
<AttachmentSourceChip memoName={item.memoName} />
</div>
</div>
<AttachmentOpenButton href={item.sourceUrl} />
</article>
))}
</div>
);
};
export const AttachmentAudioRows = ({ items }: { items: AttachmentLibraryListItem[] }) => {
return (
<div className="space-y-2.5">
{items.map((item) => (
<article
key={item.attachment.name}
className="rounded-[18px] border border-border/60 bg-background/90 p-2.5 shadow-sm shadow-black/[0.02]"
>
<AudioAttachmentItem
filename={item.attachment.filename}
sourceUrl={item.sourceUrl}
mimeType={item.attachment.type}
size={Number(item.attachment.size)}
/>
<div className="mt-2.5 flex items-center justify-between gap-2 border-t border-border/60 px-0.5 pt-2.5">
<div className="min-w-0 flex flex-wrap items-center gap-1.5">
<AttachmentMetadataLine className="min-w-0 max-w-full" items={[item.createdLabel]} />
<AttachmentSourceChip memoName={item.memoName} />
</div>
<AttachmentOpenButton href={item.sourceUrl} />
</div>
</article>
))}
</div>
);
};
export const AttachmentUnusedRows = ({ items }: { items: AttachmentLibraryListItem[] }) => {
return (
<div className="space-y-2.5">
{items.map((item) => (
<article
key={item.attachment.name}
className="flex items-center gap-2.5 rounded-[18px] border border-amber-200/70 bg-amber-50/50 p-3 shadow-sm shadow-black/[0.02] dark:border-amber-900/50 dark:bg-amber-950/10"
>
<AttachmentThumb item={item} className="h-10 w-10 shrink-0" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground" title={item.attachment.filename}>
{item.attachment.filename}
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-1.5">
<AttachmentMetadataLine className="min-w-0 max-w-full" items={[item.fileTypeLabel, item.fileSizeLabel, item.createdLabel]} />
<AttachmentSourceChip unlinkedLabelKey="attachment-library.labels.not-linked" />
</div>
</div>
<AttachmentOpenButton
className="text-amber-900/80 hover:text-amber-950 dark:text-amber-100/80 dark:hover:text-amber-50"
href={item.sourceUrl}
/>
</article>
))}
</div>
);
};

View File

@ -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 (
<div
className={cn(
"flex min-h-[18rem] flex-col items-center justify-center rounded-[28px] border border-dashed border-border/70 bg-background/80 px-6 py-16 text-center",
className,
)}
>
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted/45 text-muted-foreground">
<Icon className="h-7 w-7" />
</div>
<div className="mt-5 text-sm font-medium text-foreground">{t(titleKey)}</div>
<p className="mt-2 max-w-sm text-sm leading-6 text-muted-foreground">{t(descriptionKey)}</p>
</div>
);
};
export default AttachmentLibraryEmptyState;

View File

@ -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<string | undefined>;
}
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 (
<div
className={cn(
"flex items-center gap-1.5 overflow-x-auto whitespace-nowrap text-xs text-muted-foreground [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
className,
)}
>
{visibleItems.map((item, index) => (
<span key={`${item}-${index}`} className="contents">
{index > 0 && <span className="shrink-0 text-muted-foreground/50"></span>}
<span className="shrink-0">{item}</span>
</span>
))}
</div>
);
};
export const AttachmentSourceChip = ({
memoName,
unlinkedLabelKey = "attachment-library.labels.not-linked",
}: AttachmentSourceChipProps) => {
const t = useTranslate();
if (!memoName) {
return (
<Badge
variant="outline"
className="rounded-full border-amber-300/70 bg-amber-50/70 px-1.5 py-0.5 text-[11px] text-amber-900 dark:border-amber-700/60 dark:bg-amber-950/20 dark:text-amber-100"
>
{t(unlinkedLabelKey)}
</Badge>
);
}
return (
<Link
to={`/${memoName}`}
className="inline-flex max-w-full items-center truncate rounded-full border border-border/60 bg-muted/30 px-1.5 py-0.5 text-[11px] text-muted-foreground hover:bg-muted/50"
>
<span className="truncate">{t("attachment-library.labels.memo")}</span>
</Link>
);
};
export const AttachmentOpenButton = ({ className, href }: AttachmentOpenButtonProps) => {
const t = useTranslate();
return (
<Button
asChild
variant="ghost"
size="icon"
className={cn("size-7 shrink-0 rounded-full text-muted-foreground hover:text-foreground", className)}
>
<a href={href} target="_blank" rel="noreferrer">
<ExternalLinkIcon className="h-3.5 w-3.5" />
<span className="sr-only">{t("attachment-library.actions.open")}</span>
</a>
</Button>
);
};

View File

@ -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 (
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-3 xl:grid-cols-4">
{Array.from({ length: count }).map((_, index) => (
<div key={index} className="overflow-hidden rounded-[20px] border border-border/60 bg-background/90">
<div className="aspect-[5/4] animate-pulse bg-muted/50" />
<div className="space-y-2.5 p-3">
<div className="h-4 w-2/3 animate-pulse rounded bg-muted/50" />
<div className="h-3 w-1/2 animate-pulse rounded bg-muted/40" />
<div className="h-7 w-full animate-pulse rounded bg-muted/40" />
</div>
</div>
))}
</div>
);
};
export const AttachmentLibraryErrorState = ({ error, onRetry }: AttachmentLibraryErrorStateProps) => {
const t = useTranslate();
return (
<div className="rounded-[20px] border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-muted-foreground">{error?.message ?? t("attachment-library.errors.load")}</p>
<Button className="mt-4 rounded-full" onClick={onRetry}>
{t("attachment-library.actions.retry")}
</Button>
</div>
);
};
export const AttachmentLibraryUnusedPanel = ({ count, isDeleting, isExpanded, onDelete, onToggle }: AttachmentLibraryUnusedPanelProps) => {
const t = useTranslate();
return (
<div className="rounded-2xl border border-amber-200/70 bg-amber-50/50 p-4 dark:border-amber-900/50 dark:bg-amber-950/10">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">
{t("attachment-library.unused.title")} ({count})
</div>
<p className="mt-1 text-sm text-muted-foreground">{t("attachment-library.unused.description")}</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button type="button" variant="outline" className="rounded-full border-amber-300/70 bg-background/80 px-3" onClick={onToggle}>
{isExpanded ? t("common.close") : t("attachment-library.labels.unused")}
</Button>
<Button variant="destructive" className="rounded-full" onClick={onDelete} disabled={isDeleting}>
{isDeleting ? <LoaderCircleIcon className="h-4 w-4 animate-spin" /> : null}
{t("resource.delete-all-unused")}
</Button>
</div>
</div>
</div>
);
};

View File

@ -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 (
<div className="-mx-1 overflow-x-auto px-1 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<div className="flex min-w-max items-center gap-1.5">
{TAB_CONFIG.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.key;
return (
<Button
key={tab.key}
type="button"
variant="ghost"
className={cn(
"h-9 rounded-md px-2.5 text-sm font-medium sm:px-3",
isActive ? "bg-muted/60 text-foreground shadow-none" : "text-muted-foreground hover:bg-muted/40 hover:text-foreground",
)}
onClick={() => onTabChange(tab.key)}
>
<Icon className="h-4 w-4" />
<span>{t(`attachment-library.tabs.${tab.labelKey}`)}</span>
<span
className={cn(
"rounded-full px-1.5 py-0.5 text-[11px]",
isActive ? "bg-background text-muted-foreground" : "bg-muted/50 text-muted-foreground",
)}
>
{tab.count(stats)}
</span>
</Button>
);
})}
</div>
</div>
);
};
export default AttachmentLibraryToolbar;

View File

@ -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 (
<article className="overflow-hidden rounded-[20px] border border-border/60 bg-background/90 shadow-sm shadow-black/[0.03]">
<button type="button" className="relative block w-full cursor-pointer text-left" onClick={onPreview}>
<div className="relative aspect-[5/4] overflow-hidden bg-muted/40">
{item.kind === "video" ? (
<>
<video src={item.sourceUrl} poster={item.posterUrl} className="h-full w-full object-cover" preload="metadata" />
<div className="absolute inset-0 bg-linear-to-t from-black/35 via-black/5 to-transparent" />
<span className="absolute bottom-2.5 right-2.5 inline-flex h-8 w-8 items-center justify-center rounded-full bg-background/85 text-foreground shadow-sm backdrop-blur-sm">
<PlayIcon className="h-3.5 w-3.5 fill-current" />
</span>
</>
) : item.kind === "motion" ? (
<MotionPhotoPreview
posterUrl={item.posterUrl}
motionUrl={item.previewItem.kind === "motion" ? item.previewItem.motionUrl : item.sourceUrl}
alt={item.filename}
presentationTimestampUs={item.previewItem.kind === "motion" ? item.previewItem.presentationTimestampUs : undefined}
containerClassName="h-full w-full"
mediaClassName="h-full w-full object-cover"
badgeClassName="left-3 top-3"
/>
) : (
<img src={item.posterUrl} alt={item.filename} className="h-full w-full object-cover" loading="lazy" decoding="async" />
)}
</div>
</button>
<div className="flex flex-col gap-2 p-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 truncate text-sm font-medium leading-5 text-foreground" title={item.filename}>
{item.filename}
</div>
{item.kind === "motion" && (
<Badge variant="outline" className="rounded-full border-border/60 bg-background/70 px-1.5 py-0.5 text-[11px]">
{t("attachment-library.labels.live")}
</Badge>
)}
</div>
<div className="flex items-center justify-between gap-2">
<AttachmentMetadataLine
className="min-w-0 flex-1"
items={[item.fileTypeLabel, item.createdLabel !== "—" ? item.createdLabel : undefined]}
/>
<AttachmentOpenButton href={item.sourceUrl} />
</div>
</div>
</article>
);
};
const AttachmentMediaGrid = ({ groups, onPreview }: AttachmentMediaGridProps) => {
return (
<div className="flex flex-col gap-6 sm:gap-8">
{groups.map((group) => (
<section key={group.key} className="space-y-2.5 sm:space-y-3">
<div className="flex items-center gap-2.5">
<div className="text-xs font-medium uppercase tracking-[0.24em] text-muted-foreground">{group.label}</div>
<div className="h-px flex-1 bg-border/70" />
</div>
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-3 xl:grid-cols-4">
{group.items.map((item) => (
<AttachmentMediaCard key={item.id} item={item} onPreview={() => onPreview(item.previewItem.id)} />
))}
</div>
</section>
))}
</div>
);
};
export default AttachmentMediaGrid;

View File

@ -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";

View File

@ -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<string, AttachmentLibraryMediaItem[]>();
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<AttachmentLibraryStats>(
() => ({
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,
};
}

View File

@ -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<ListAttachmentsRequest> = {}, options?: { enabled?: boolean }) {
return useInfiniteQuery({
queryKey: attachmentKeys.list(request),
queryFn: async ({ pageParam }) => {
const response = await attachmentServiceClient.listAttachments(
create(ListAttachmentsRequestSchema, {
...request,
pageToken: pageParam || "",
} as Record<string, unknown>),
);
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() });
},
});
}

View File

@ -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",

View File

@ -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<string, Attachment[]> => {
const grouped = new Map<string, Attachment[]>();
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) => (
<div className="w-24 sm:w-32 h-auto flex flex-col justify-start items-start">
<div className="w-24 h-24 flex justify-center items-center sm:w-32 sm:h-32 border border-border overflow-clip rounded-xl cursor-pointer hover:shadow hover:opacity-80">
<AttachmentIcon attachment={attachment} strokeWidth={0.5} />
</div>
<div className="w-full max-w-full flex flex-row justify-between items-center mt-1 px-1">
<p className="text-xs shrink text-muted-foreground truncate">{attachment.filename}</p>
{attachment.memo && (
<Link to={`/${attachment.memo}`} className="text-primary hover:opacity-80 transition-opacity shrink-0 ml-1" aria-label="View memo">
<ExternalLinkIcon className="w-3 h-3" />
</Link>
)}
</div>
</div>
);
const Attachments = () => {
const t = useTranslate();
const md = useMediaQuery("md");
const loadingState = useLoading();
const deleteUnusedAttachmentsDialog = useDialog();
const { mutateAsync: deleteAttachment } = useDeleteAttachment();
const [activeTab, setActiveTab] = useState<AttachmentLibraryTab>("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<Attachment[]>([]);
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<HTMLInputElement>) => {
setSearchQuery(e.target.value);
}, []);
const renderContent = () => {
if (isLoading) {
return <AttachmentLibrarySkeletonGrid />;
}
if (isError) {
return <AttachmentLibraryErrorState error={error instanceof Error ? error : undefined} onRetry={() => refetch()} />;
}
if (currentItemsCount === 0) {
return <AttachmentLibraryEmptyState tab={activeTab} />;
}
if (activeTab === "media") {
return <AttachmentMediaGrid groups={mediaGroups} onPreview={handlePreview} />;
}
if (activeTab === "documents") {
return <AttachmentDocumentRows items={documentItems} />;
}
if (activeTab === "audio") {
return <AttachmentAudioRows items={audioItems} />;
}
return null;
};
return (
<section className="@container w-full max-w-5xl min-h-full flex flex-col justify-start items-center sm:pt-3 md:pt-6 pb-8">
<section className="@container w-full min-h-full pb-10 sm:pt-3 md:pt-6">
{!md && <MobileHeader />}
<div className="w-full px-4 sm:px-6">
<div className="w-full border border-border flex flex-col justify-start items-start px-4 py-3 rounded-xl bg-background text-foreground">
<div className="relative w-full flex flex-row justify-between items-center">
<p className="py-1 flex flex-row justify-start items-center select-none opacity-80">
<PaperclipIcon className="w-6 h-auto mr-1 opacity-80" />
<span className="text-lg">{t("common.attachments")}</span>
</p>
<div>
<div className="relative max-w-32">
<SearchIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input className="pl-9" placeholder={t("common.search")} value={searchQuery} onChange={handleSearchChange} />
</div>
</div>
</div>
<div className="w-full flex flex-col justify-start items-start mt-4 mb-6">
{loadingState.isLoading ? (
<div className="w-full h-32 flex flex-col justify-center items-center">
<p className="w-full text-center text-base my-6 mt-8">{t("resource.fetching-data")}</p>
</div>
) : (
<>
{filteredAttachments.length === 0 ? (
<div className="w-full mt-8 mb-8 flex flex-col justify-center items-center italic">
<Empty />
<p className="mt-4 text-muted-foreground">{t("message.no-data")}</p>
</div>
) : (
<>
<div className={"w-full h-auto px-2 flex flex-col justify-start items-start gap-y-8"}>
{Array.from(groupedAttachments.entries()).map(([monthStr, attachments]) => {
return (
<div key={monthStr} className="w-full flex flex-row justify-start items-start">
<div className="w-16 sm:w-24 pt-4 sm:pl-4 flex flex-col justify-start items-start">
<span className="text-sm opacity-60">{dayjs(monthStr).year()}</span>
<span className="font-medium text-xl">
{dayjs(monthStr).toDate().toLocaleString(i18n.language, { month: "short" })}
</span>
</div>
<div className="w-full max-w-[calc(100%-4rem)] sm:max-w-[calc(100%-6rem)] flex flex-row justify-start items-start gap-4 flex-wrap">
{attachments.map((attachment) => (
<AttachmentItem key={attachment.name} attachment={attachment} />
))}
</div>
</div>
);
})}
{unusedAttachments.length > 0 && (
<>
<Separator />
<div className="w-full flex flex-row justify-start items-start">
<div className="w-16 sm:w-24 sm:pl-4 flex flex-col justify-start items-start"></div>
<div className="w-full max-w-[calc(100%-4rem)] sm:max-w-[calc(100%-6rem)] flex flex-row justify-start items-start gap-4 flex-wrap">
<div className="w-full flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<div className="flex flex-row items-center gap-2">
<span className="text-muted-foreground">{t("resource.unused-resources")}</span>
<span className="text-muted-foreground opacity-80">({unusedAttachments.length})</span>
</div>
<div>
<Button variant="destructive" onClick={() => deleteUnusedAttachmentsDialog.open()} size="sm">
<Trash />
{t("resource.delete-all-unused")}
</Button>
</div>
</div>
{unusedAttachments.map((attachment) => (
<AttachmentItem key={attachment.name} attachment={attachment} />
))}
</div>
</div>
</>
)}
</div>
{nextPageToken && (
<div className="w-full flex flex-row justify-center items-center mt-4">
<Button variant="outline" size="sm" onClick={handleLoadMore} disabled={isLoadingMore}>
{isLoadingMore ? t("resource.fetching-data") : t("memo.load-more")}
</Button>
</div>
)}
</>
)}
</>
)}
</div>
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5 px-4 sm:gap-6 sm:px-6">
<AttachmentLibraryToolbar activeTab={activeTab} onTabChange={setActiveTab} stats={stats} />
{stats.unused > 0 && (
<AttachmentLibraryUnusedPanel
count={stats.unused}
isDeleting={isDeletingUnused}
isExpanded={showUnusedSection}
onDelete={() => deleteUnusedAttachmentsDialog.open()}
onToggle={() => setShowUnusedSection((state) => !state)}
/>
)}
<div className="min-h-[16rem] pt-1">
{renderContent()}
{hasNextPage && (
<div className="mt-6 flex justify-center">
<Button variant="outline" className="rounded-full px-4" onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
{isFetchingNextPage ? <LoaderCircleIcon className="h-4 w-4 animate-spin" /> : null}
{isFetchingNextPage ? t("resource.fetching-data") : t("memo.load-more")}
</Button>
</div>
)}
{!isLoading && isFetching && !isFetchingNextPage && (
<div className="mt-4 text-center text-xs text-muted-foreground">{t("resource.fetching-data")}</div>
)}
</div>
{showUnusedSection && stats.unused > 0 && (
<div className="space-y-4 pt-1">
<div className="text-sm font-medium text-foreground">{t("attachment-library.unused.title")}</div>
<AttachmentUnusedRows items={unusedItems} />
</div>
)}
</div>
<ConfirmDialog
open={deleteUnusedAttachmentsDialog.isOpen}
onOpenChange={deleteUnusedAttachmentsDialog.setOpen}
title={t("resource.delete-all-unused-confirm")}
description={t("attachment-library.unused.confirm-description")}
confirmLabel={t("common.delete")}
cancelLabel={t("common.cancel")}
onConfirm={handleDeleteUnusedAttachments}
confirmVariant="destructive"
/>
<PreviewImageDialog
open={previewState.open}
onOpenChange={(open) => setPreviewState((prev) => ({ ...prev, open }))}
items={mediaPreviewItems}
initialIndex={previewState.initialIndex}
/>
</section>
);
};