mirror of https://github.com/usememos/memos.git
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:
parent
7ac9989d43
commit
2cbc70762b
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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";
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue