2026-06-01 16:42:36 +07:00
|
|
|
import type { MessageRecord } from "../../../shared/api/client";
|
2026-06-02 21:06:42 +07:00
|
|
|
import { parseMetadata } from "../../../entities/message/types";
|
2026-06-03 03:18:04 +07:00
|
|
|
import { EmptyStateMascot } from "../../../widgets/mascot/ChibiMascot";
|
2026-06-01 16:42:36 +07:00
|
|
|
|
|
|
|
|
interface ImageItem {
|
|
|
|
|
url: string;
|
|
|
|
|
title: string;
|
|
|
|
|
kind: "attachment" | "embed" | "sticker";
|
|
|
|
|
message: MessageRecord;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 03:18:04 +07:00
|
|
|
function kindBadge(kind: ImageItem["kind"]): string {
|
|
|
|
|
switch (kind) {
|
|
|
|
|
case "sticker":
|
|
|
|
|
return "bg-pink-100 text-pink-700 border-pink-200";
|
|
|
|
|
case "attachment":
|
|
|
|
|
return "bg-primary-soft text-primary border-primary/30";
|
|
|
|
|
case "embed":
|
|
|
|
|
return "bg-purple-100 text-purple-700 border-purple-200";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 16:42:36 +07:00
|
|
|
export function ImageGrid({ messages }: { messages: MessageRecord[] }) {
|
|
|
|
|
const images: ImageItem[] = [];
|
|
|
|
|
|
|
|
|
|
for (const message of messages) {
|
|
|
|
|
const metadata = parseMetadata(message.metadata);
|
|
|
|
|
|
|
|
|
|
// Stickers
|
|
|
|
|
for (const sticker of metadata.stickers ?? []) {
|
|
|
|
|
if (sticker.url) {
|
2026-06-01 21:44:29 +07:00
|
|
|
images.push({
|
|
|
|
|
url: sticker.url,
|
|
|
|
|
title: sticker.name || "sticker",
|
|
|
|
|
kind: "sticker",
|
|
|
|
|
message,
|
|
|
|
|
});
|
2026-06-01 16:42:36 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Attachments
|
|
|
|
|
for (const attachment of metadata.attachments ?? []) {
|
2026-06-01 21:44:29 +07:00
|
|
|
if (
|
|
|
|
|
attachment.url &&
|
|
|
|
|
(attachment.contentType?.startsWith("image/") ||
|
|
|
|
|
/\.(png|jpe?g|gif|webp)$/i.test(attachment.name))
|
|
|
|
|
) {
|
|
|
|
|
images.push({
|
|
|
|
|
url: attachment.url,
|
|
|
|
|
title: attachment.name,
|
|
|
|
|
kind: "attachment",
|
|
|
|
|
message,
|
|
|
|
|
});
|
2026-06-01 16:42:36 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Embed images
|
|
|
|
|
for (const embed of metadata.embeds ?? []) {
|
|
|
|
|
for (const imgUrl of [embed.image, embed.thumbnail].filter(Boolean)) {
|
2026-06-01 21:44:29 +07:00
|
|
|
images.push({
|
|
|
|
|
url: imgUrl as string,
|
|
|
|
|
title: embed.title || "embed image",
|
|
|
|
|
kind: "embed",
|
|
|
|
|
message,
|
|
|
|
|
});
|
2026-06-01 16:42:36 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (images.length === 0) {
|
2026-06-01 21:44:29 +07:00
|
|
|
return (
|
2026-06-03 03:18:04 +07:00
|
|
|
<EmptyStateMascot variant="thinking" message="No images yet~" />
|
2026-06-01 21:44:29 +07:00
|
|
|
);
|
2026-06-01 16:42:36 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
|
|
|
|
{images.map((image, index) => {
|
|
|
|
|
// Stable key using message.id + url
|
|
|
|
|
const stableKey = `${image.message.id}-${image.kind}-${index}`;
|
|
|
|
|
return (
|
|
|
|
|
<a
|
|
|
|
|
key={stableKey}
|
|
|
|
|
href={image.url}
|
|
|
|
|
target="_blank"
|
|
|
|
|
rel="noreferrer"
|
2026-06-03 03:18:04 +07:00
|
|
|
className="group overflow-hidden rounded-2xl border border-primary/20 bg-white shadow-sm transition-all hover:border-primary/40 hover:shadow-md"
|
2026-06-01 16:42:36 +07:00
|
|
|
>
|
|
|
|
|
<div className="relative aspect-video overflow-hidden">
|
|
|
|
|
{image.kind === "sticker" ? (
|
|
|
|
|
<img
|
|
|
|
|
src={image.url}
|
|
|
|
|
alt={image.title}
|
|
|
|
|
className="h-full w-full object-contain bg-muted/30 p-2 transition-transform group-hover:scale-105"
|
|
|
|
|
loading="lazy"
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<img
|
|
|
|
|
src={image.url}
|
|
|
|
|
alt={image.title}
|
|
|
|
|
className="h-full w-full object-cover transition-transform group-hover:scale-105"
|
|
|
|
|
loading="lazy"
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2026-06-03 03:18:04 +07:00
|
|
|
<span
|
|
|
|
|
className={`absolute right-2 top-2 rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider shadow-sm backdrop-blur ${kindBadge(image.kind)}`}
|
|
|
|
|
>
|
2026-06-01 16:42:36 +07:00
|
|
|
{image.kind}
|
2026-06-03 03:18:04 +07:00
|
|
|
</span>
|
2026-06-01 16:42:36 +07:00
|
|
|
</div>
|
|
|
|
|
<div className="p-3">
|
|
|
|
|
<div className="truncate text-sm font-medium">{image.title}</div>
|
|
|
|
|
<div className="flex items-center gap-2">
|
2026-06-03 03:18:04 +07:00
|
|
|
<div className="h-4 w-4 overflow-hidden rounded-full ring-1 ring-primary/30">
|
2026-06-01 16:42:36 +07:00
|
|
|
<img
|
2026-06-01 21:44:29 +07:00
|
|
|
src={
|
|
|
|
|
image.message.avatar_url ??
|
|
|
|
|
"https://cdn.discordapp.com/embed/avatars/0.png"
|
|
|
|
|
}
|
2026-06-01 16:42:36 +07:00
|
|
|
alt=""
|
|
|
|
|
className="h-full w-full object-cover"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2026-06-01 21:44:29 +07:00
|
|
|
<span className="truncate text-xs text-muted-foreground">
|
|
|
|
|
{image.message.username}
|
|
|
|
|
</span>
|
2026-06-01 16:42:36 +07:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</a>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|