Skip to content

Commit 6d71c2f

Browse files
authored
attachment previews for mails (#384)
1 parent 5d33606 commit 6d71c2f

File tree

2 files changed

+142
-88
lines changed

2 files changed

+142
-88
lines changed
Lines changed: 127 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,76 +1,133 @@
1-
import React, { useEffect, useState } from "react";
1+
"use client";
2+
3+
import React, { useEffect, useMemo, useState } from "react";
24
import { MessageAttachmentEntity } from "@db";
35
import { PublicConfig } from "@schema";
46
import { createClient } from "@/lib/supabase/client";
7+
import { FileText, FileImage, FileVideo, FileAudio, CalendarDays, Paperclip } from "lucide-react";
8+
import mime from "mime-types";
59

6-
function EditorAttachmentItem({
7-
attachment,
8-
publicConfig,
9-
}: {
10-
attachment: MessageAttachmentEntity;
11-
publicConfig: PublicConfig;
12-
}) {
13-
const supabase = createClient(publicConfig);
14-
15-
const [url, setUrl] = useState<string | null>(null);
16-
17-
const generateUrl = async () => {
18-
const { data } = await supabase.storage
19-
.from("attachments")
20-
.createSignedUrl(attachment.path, 60, {
21-
download: true,
22-
});
23-
24-
setUrl(data?.signedUrl || null);
25-
};
26-
27-
useEffect(() => {
28-
generateUrl();
29-
}, [attachment]);
30-
31-
return (
32-
<>
33-
<a
34-
key={attachment.id}
35-
href={url || "#"}
36-
target={"_blank"}
37-
rel={"noreferrer noopener"}
38-
download
39-
className={"flex items-center gap-4 mb-2 hover:bg-base-200 p-2 rounded"}
40-
>
41-
<div
42-
className={
43-
"w-8 h-8 flex items-center justify-center bg-base-300 rounded"
44-
}
45-
>
46-
<svg
47-
xmlns="http://www.w3.org/2000/svg"
48-
className="h-4 w-4"
49-
fill="none"
50-
viewBox="0 0 24 24"
51-
stroke="currentColor"
52-
strokeWidth={2}
53-
>
54-
<path
55-
strokeLinecap="round"
56-
strokeLinejoin="round"
57-
d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"
58-
/>
59-
</svg>
60-
</div>
61-
<div className={"flex flex-col"}>
62-
<div className={"text-sm font-medium underline"}>
63-
{attachment.filenameOriginal}
64-
</div>
65-
{attachment?.sizeBytes && (
66-
<div className={"text-xs text-muted-foreground"}>
67-
{(attachment?.sizeBytes / 1024).toFixed(2)} KB
68-
</div>
69-
)}
70-
</div>
71-
</a>
72-
</>
73-
);
10+
function formatBytes(bytes?: number | null) {
11+
if (!bytes || bytes <= 0) return null;
12+
const kb = bytes / 1024;
13+
if (kb < 1024) return `${kb.toFixed(1)} KB`;
14+
return `${(kb / 1024).toFixed(1)} MB`;
15+
}
16+
17+
function getKind(a: MessageAttachmentEntity) {
18+
const ct = (a.contentType || "").toLowerCase();
19+
const name = (a.filenameOriginal || "").toLowerCase();
20+
const ext = name.includes(".") ? name.split(".").pop() || "" : "";
21+
const guessed = (ext ? String(mime.lookup(ext) || "") : "").toLowerCase();
22+
const type = ct || guessed;
23+
24+
if (type.startsWith("image/")) return "image";
25+
if (type === "application/pdf") return "pdf";
26+
if (type.startsWith("video/")) return "video";
27+
if (type.startsWith("audio/")) return "audio";
28+
if (type.includes("text/calendar") || type.includes("application/ics") || ext === "ics") return "calendar";
29+
if (type.startsWith("text/")) return "text";
30+
return "file";
7431
}
7532

76-
export default EditorAttachmentItem;
33+
function KindIcon({ kind }: { kind: string }) {
34+
if (kind === "image") return <FileImage className="h-4 w-4" />;
35+
if (kind === "pdf") return <FileText className="h-4 w-4" />;
36+
if (kind === "video") return <FileVideo className="h-4 w-4" />;
37+
if (kind === "audio") return <FileAudio className="h-4 w-4" />;
38+
if (kind === "calendar") return <CalendarDays className="h-4 w-4" />;
39+
return <Paperclip className="h-4 w-4" />;
40+
}
41+
42+
export default function EditorAttachmentItem({
43+
attachment,
44+
publicConfig,
45+
}: {
46+
attachment: MessageAttachmentEntity;
47+
publicConfig: PublicConfig;
48+
}) {
49+
const supabase = useMemo(() => createClient(publicConfig), [publicConfig]);
50+
51+
const [url, setUrl] = useState<string | null>(null);
52+
53+
useEffect(() => {
54+
let cancelled = false;
55+
56+
(async () => {
57+
const { data } = await supabase.storage
58+
.from("attachments")
59+
.createSignedUrl(String(attachment.path), 300);
60+
61+
if (!cancelled) setUrl(data?.signedUrl || null);
62+
})();
63+
64+
return () => {
65+
cancelled = true;
66+
};
67+
}, [attachment.id, attachment.path, supabase]);
68+
69+
const kind = getKind(attachment);
70+
const sizeLabel = formatBytes(attachment.sizeBytes);
71+
72+
const title = attachment.filenameOriginal || "attachment";
73+
const subtitle = [attachment.contentType || null, sizeLabel].filter(Boolean).join(" · ");
74+
75+
return (
76+
<a
77+
href={url || "#"}
78+
target="_blank"
79+
rel="noreferrer noopener"
80+
className="group overflow-hidden rounded-2xl border border-neutral-200/70 bg-white transition dark:border-neutral-800 dark:bg-neutral-950"
81+
>
82+
<div className="h-44 bg-neutral-50 dark:bg-neutral-900/40 overflow-hidden">
83+
{url && kind === "image" ? (
84+
<img src={url} alt={title} className="h-full w-full object-cover" />
85+
) : url && kind === "video" ? (
86+
<video src={url} className="h-full w-full object-cover" muted controls={false} />
87+
) : url && kind === "audio" ? (
88+
<div className="h-full w-full flex items-center justify-center">
89+
<div className="flex items-center gap-2 text-sm text-neutral-600 dark:text-neutral-300">
90+
<KindIcon kind={kind} />
91+
<span>Audio</span>
92+
</div>
93+
</div>
94+
) : url && kind === "pdf" ? (
95+
<iframe
96+
src={url}
97+
className={"h-full w-full scale-120 overflow-hidden object-cover"}
98+
style={{
99+
pointerEvents: "none",
100+
border: "0",
101+
}}
102+
/>
103+
) : (
104+
<div className="h-full w-full flex items-center justify-center">
105+
<div className="flex items-center gap-2 text-sm text-neutral-600 dark:text-neutral-300">
106+
<KindIcon kind={kind} />
107+
<span className="capitalize">{kind === "file" ? "Attachment" : kind}</span>
108+
</div>
109+
</div>
110+
)}
111+
</div>
112+
113+
<div className="p-3">
114+
<div className="flex items-start gap-2">
115+
<div className="mt-0.5 flex h-8 w-8 items-center justify-center rounded-xl bg-neutral-100 text-neutral-700 dark:bg-neutral-900 dark:text-neutral-200">
116+
<KindIcon kind={kind} />
117+
</div>
118+
119+
<div className="min-w-0">
120+
<div className="truncate text-sm font-semibold text-neutral-900 dark:text-neutral-100 group-hover:underline">
121+
{title}
122+
</div>
123+
{subtitle ? (
124+
<div className="truncate text-xs text-neutral-600 dark:text-neutral-300">
125+
{subtitle}
126+
</div>
127+
) : null}
128+
</div>
129+
</div>
130+
</div>
131+
</a>
132+
);
133+
}

apps/web/components/mailbox/default/email-renderer.tsx

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -392,24 +392,21 @@ function EmailRenderer({
392392

393393
{children}
394394

395-
{attachments?.length > 0 && (
396-
<div className={"border-t border-dotted"}>
397-
<div className={"font-semibold my-4"}>
398-
{attachments?.length} attachments
399-
</div>
400-
<div className={"flex flex-col"}>
401-
{attachments?.map((attachment) => {
402-
return (
403-
<EditorAttachmentItem
404-
key={attachment.id}
405-
attachment={attachment}
406-
publicConfig={publicConfig}
407-
/>
408-
);
409-
})}
410-
</div>
411-
</div>
412-
)}
395+
{attachments?.length > 0 && (
396+
<div className="border-t border-dotted py-4">
397+
<div className="font-semibold mb-4">{attachments.length} attachments</div>
398+
399+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
400+
{attachments.map((attachment) => (
401+
<EditorAttachmentItem
402+
key={attachment.id}
403+
attachment={attachment}
404+
publicConfig={publicConfig}
405+
/>
406+
))}
407+
</div>
408+
</div>
409+
)}
413410

414411
{threadIndex === numberOfMessages - 1 && !showEditor && (
415412
<div className={"flex gap-6"}>

0 commit comments

Comments
 (0)