feat: chat
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Attachments under a message bubble.
|
||||
*
|
||||
* Media (images and videos) tile into a grid and open in a lightbox; links and
|
||||
* files keep their own row treatments, since a link card and a photo are read
|
||||
* very differently.
|
||||
*
|
||||
* Media is fetched through the BFF's stable /chat/attachments/{id} URL, which
|
||||
* re-checks membership and redirects to a short-lived signed URL. The object
|
||||
* itself lives in a private bucket and is never public.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { FileText } from "lucide-react";
|
||||
import { LinkPreviewCard } from "@/components/chat/link-preview-card";
|
||||
import { MediaGrid } from "@/components/chat/media-grid";
|
||||
import { MediaLightbox } from "@/components/chat/media-lightbox";
|
||||
import { attachmentUrl, type Attachment } from "@/lib/chat";
|
||||
|
||||
function humanSize(bytes?: number | null): string {
|
||||
if (!bytes) return "";
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let n = bytes;
|
||||
let i = 0;
|
||||
while (n >= 1024 && i < units.length - 1) {
|
||||
n /= 1024;
|
||||
i += 1;
|
||||
}
|
||||
return `${n < 10 && i > 0 ? n.toFixed(1) : Math.round(n)} ${units[i]}`;
|
||||
}
|
||||
|
||||
export function MessageAttachments({
|
||||
attachments,
|
||||
}: {
|
||||
attachments: Attachment[];
|
||||
}) {
|
||||
const [lightboxAt, setLightboxAt] = useState<number | null>(null);
|
||||
|
||||
if (!attachments || attachments.length === 0) return null;
|
||||
|
||||
// Only images and videos tile; everything else keeps its own row.
|
||||
const media = attachments.filter(
|
||||
(a) => a.kind === "image" || a.kind === "video",
|
||||
);
|
||||
const rest = attachments.filter(
|
||||
(a) => a.kind !== "image" && a.kind !== "video",
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-1 flex flex-col gap-1.5">
|
||||
{media.length > 0 && (
|
||||
<MediaGrid items={media} onOpen={(i) => setLightboxAt(i)} />
|
||||
)}
|
||||
|
||||
{rest.map((a) => {
|
||||
switch (a.kind) {
|
||||
case "link":
|
||||
return (
|
||||
<LinkPreviewCard
|
||||
key={a.id}
|
||||
compact
|
||||
preview={{
|
||||
url: a.url ?? "",
|
||||
title: a.title ?? undefined,
|
||||
description: a.description ?? undefined,
|
||||
site_name: a.site_name ?? undefined,
|
||||
image_url: a.image_url ?? undefined,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
case "audio":
|
||||
return (
|
||||
<audio
|
||||
key={a.id}
|
||||
src={attachmentUrl(a.id)}
|
||||
controls
|
||||
className="w-56"
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<a
|
||||
key={a.id}
|
||||
href={attachmentUrl(a.id)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 rounded-lg bg-black/5 px-2 py-1.5 text-xs hover:opacity-90 dark:bg-white/10"
|
||||
>
|
||||
<FileText className="size-4 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{a.file_name ?? "Attachment"}
|
||||
</span>
|
||||
{a.size_bytes ? (
|
||||
<span className="shrink-0 opacity-70">
|
||||
{humanSize(a.size_bytes)}
|
||||
</span>
|
||||
) : null}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
|
||||
{lightboxAt !== null && (
|
||||
<MediaLightbox
|
||||
items={media}
|
||||
startIndex={lightboxAt}
|
||||
onClose={() => setLightboxAt(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user