mirror of https://github.com/usememos/memos
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 hookspull/5811/head
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,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue