WFDASH-WEBAPP/src/components/quote-email-card.tsx
SJCoetz 7a68cafe35 chore: add logos, opencode config and build assets
This commit includes the temporary Next.js build cache files, public brand
logos, and project configuration added in this change.
2026-07-27 08:21:20 +02:00

772 lines
28 KiB
TypeScript

"use client";
/**
* QuoteEmailCard — shows the original email (subject, sender, received_at,
* message body) and the list of attachments for a quote that was created
* from an inbound email. Renders nothing for manually-created quotes (no
* email data) so the page stays clean.
*
* The quoter uses this card to verify the AI-extracted line items against
* what the original email actually said.
*
* Body handling: the workflow may store either HTML (raw `html` from IMAP,
* or the cleaner `textAsHtml`) or plain text. We auto-detect, sanitize any
* HTML before injecting it via `dangerouslySetInnerHTML`, and fall back to
* paragraph-split plain text rendering for everything else.
*/
import { useEffect, useState } from "react";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import {
Mail,
Calendar,
FileText,
FileImage,
FileSpreadsheet,
File as FileIcon,
Download,
Paperclip,
Inbox,
ExternalLink,
Truck,
X,
type LucideIcon,
} from "lucide-react";
import getPocketBase from "@/lib/pocketbase";
import type { Quote, QuoteAttachment, QuoteAttachmentKind } from "@/lib/types";
interface QuoteEmailCardProps {
quote: Quote;
attachments: QuoteAttachment[];
}
function formatBytes(bytes: number): string {
if (!bytes || bytes < 0) return "0 B";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
function formatReceivedAt(iso: string): string {
if (!iso) return "—";
try {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString("en-ZA", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
}
/**
* Resolve `kind` to one of our four known kinds. Older attachments (uploaded
* before the v7 multi-attachment workflow wrote `kind`) come back as null /
* undefined — we sniff `content_type` / `filename` as a fallback so the UI
* still picks a sensible icon, then surface the resolved value via `kindLabel`.
*/
function resolveKind(att: QuoteAttachment): QuoteAttachmentKind {
const declared = (att.kind || "").toString().trim().toLowerCase();
if (declared === "pdf" || declared === "image" || declared === "doc" || declared === "other") {
return declared;
}
const ct = (att.content_type || "").toLowerCase();
const fn = (att.filename || "").toLowerCase();
if (ct === "application/pdf" || fn.endsWith(".pdf")) return "pdf";
if (ct.startsWith("image/") || /\.(png|jpe?g|gif|webp|bmp|svg)$/.test(fn)) return "image";
if (
ct.includes("officedocument") ||
ct.includes("msword") ||
ct.includes("ms-excel") ||
/\.(docx?|xlsx?|csv|ods|rtf)$/.test(fn)
) {
return "doc";
}
return "other";
}
// Stable lookup table — used in places where calling a function during
// render would trip `react-hooks/static-components`. The returned value
// is one of the module-level LucideIcon imports above, so reference
// identity is stable across renders for a given kind.
const ATTACHMENT_ICONS: Record<QuoteAttachmentKind, LucideIcon> = {
image: FileImage,
pdf: FileText,
doc: FileSpreadsheet,
other: FileIcon,
// "delivery_note" uses the Truck icon — a small visual cue
// that this is the signed delivery note (a physical document)
// vs the quote's own PDF. Mirrors the Truck icon used on
// the Delivery Note tabs throughout the app.
delivery_note: Truck,
};
/**
* Build a download URL for a quote_attachments row. PocketBase serves
* files at `/api/files/{collection}/{recordId}/{filename}` and accepts the
* user's auth token as a `?token=` query param. The auth provider stores
* the token in localStorage, so a plain <a href> won't carry the cookie
* — we embed the token explicitly.
*/
function attachmentUrl(attachment: QuoteAttachment): string {
const pb = getPocketBase();
const token = pb.authStore.token;
// `attachment.file` is an array of PB file tokens (the first element
// is the actual stored filename). Pass it through as-is — the JS SDK
// accepts both string and string[].
const base = pb.files.getURL(attachment, attachment.file as unknown as string, {
query: token ? { token } : undefined,
});
const baseUrl =
process.env.NEXT_PUBLIC_POCKETBASE_URL || "http://127.0.0.1:8090";
if (base.startsWith("http")) return base;
return `${baseUrl.replace(/\/$/, "")}${base}`;
}
function senderName(sender: string): string {
if (!sender) return "(unknown sender)";
const local = sender.split("@")[0];
return local || sender;
}
function senderInitials(sender: string): string {
const name = senderName(sender);
const cleaned = name.replace(/[._-]+/g, " ").trim();
const parts = cleaned.split(/\s+/).filter(Boolean);
if (parts.length === 0) return "?";
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
/**
* Heuristic: does this string look like HTML rather than plain text?
* We only need to catch the common cases — quoted `>` lines and the
* IMAP node's `html` / `textAsHtml` fields. If the first non-whitespace
* char is `<` OR there are HTML tags anywhere in the first 2KB, it's HTML.
*/
function looksLikeHtml(s: string): boolean {
if (!s) return false;
const head = s.slice(0, 2048).trimStart();
if (head.startsWith("<!DOCTYPE") || head.startsWith("<html")) return true;
// If the very first char is `<`, it's almost certainly HTML. We accept
// a one-character false positive over a wall of literal `<div>` tags.
if (head.startsWith("<")) return true;
return /<\/?(p|div|br|ul|ol|li|table|tr|td|h[1-6]|a\s|span|strong|em|b|i|img)\b/i.test(head);
}
/**
* Minimal HTML sanitizer for inbound email bodies. We allow common safe
* tags + a small set of safe attributes, strip everything else, and
* neutralise event handlers and javascript: URLs.
*
* This is NOT a general-purpose sanitizer (use DOMPurify for that). It is
* scoped to the tags that actually appear in legitimate RFQ emails:
* structural, list, link, image, basic formatting.
*/
const ALLOWED_TAGS = new Set([
"a", "b", "blockquote", "br", "code", "div", "em", "h1", "h2", "h3",
"h4", "h5", "h6", "hr", "i", "img", "li", "ol", "p", "pre", "s",
"small", "span", "strong", "sub", "sup", "table", "tbody", "td",
"th", "thead", "tr", "u", "ul",
]);
const ALLOWED_ATTRS: Record<string, Set<string>> = {
a: new Set(["href", "title"]),
img: new Set(["src", "alt", "width", "height"]),
th: new Set(["colspan", "rowspan", "scope"]),
td: new Set(["colspan", "rowspan"]),
table: new Set(["border", "cellpadding", "cellspacing"]),
};
function sanitizeHtml(input: string): string {
if (typeof window === "undefined" || !input) return "";
// Strip the most dangerous blocks first so a stray open tag can't leak
// content past the regex scrub.
let html = input
.replace(/<script\b[\s\S]*?<\/script\s*>/gi, "")
.replace(/<style\b[\s\S]*?<\/style\s*>/gi, "")
.replace(/<iframe\b[\s\S]*?<\/iframe\s*>/gi, "")
.replace(/<object\b[\s\S]*?<\/object\s*>/gi, "")
.replace(/<embed\b[^>]*>/gi, "")
.replace(/<form\b[\s\S]*?<\/form\s*>/gi, "")
.replace(/<base\b[^>]*>/gi, "");
// Now strip dangerous attributes and bad URL schemes from surviving tags.
html = html.replace(/<(\/?)([a-zA-Z][a-zA-Z0-9]*)(\s[^>]*?)?(\/?)>/g, (_match, slash, tagRaw, attrs) => {
const tag = (tagRaw as string).toLowerCase();
if (!ALLOWED_TAGS.has(tag)) return "";
if (slash) return `</${tag}>`;
if (!attrs) return `<${tag}>`;
const allowed = ALLOWED_ATTRS[tag] || new Set<string>();
// Parse just the attributes we allow.
const attrPairs: string[] = [];
const attrRe = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/g;
let m: RegExpExecArray | null;
while ((m = attrRe.exec(attrs)) !== null) {
const name = m[1].toLowerCase();
const value = m[2] ?? m[3] ?? m[4] ?? "";
if (!allowed.has(name)) continue;
// Block event handlers (defence in depth — `on*` aren't in allowed).
if (name.startsWith("on")) continue;
// Block javascript: in href / src.
if ((name === "href" || name === "src") && /^\s*javascript:/i.test(value)) continue;
// Force external links to open safely.
const safeValue = value
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
attrPairs.push(`${name}="${safeValue}"`);
}
return `<${tag}${attrPairs.length ? " " + attrPairs.join(" ") : ""}>`;
});
return html;
}
/**
* Auto-linkify bare URLs in plain-text bodies (http/https/mailto). Email
* clients do this by default; if we don't, "see https://example.com" stays
* unclickable and the quoter has to copy-paste.
*/
function linkifyText(text: string): string {
const escaped = text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
return escaped.replace(
/(\b(?:https?:\/\/|mailto:)[^\s<]+)/gi,
(url) => `<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`
);
}
/**
* Split a plain-text body into paragraphs (blank-line separated) and
* collect `> ...` lines into a separate quoted block. Returns ready-to-
* render HTML: paragraphs wrapped in <p>, the quote in a left-bordered
* block. Preserves single line breaks within a paragraph via <br>.
*/
function renderPlainTextAsHtml(text: string): string {
if (!text) return "";
const lines = text.split(/\r?\n/);
const out: string[] = [];
let paragraph: string[] = [];
let quote: string[] = [];
const flushParagraph = () => {
if (paragraph.length === 0) return;
const joined = paragraph
.map((l) => linkifyText(l))
.join("<br />");
out.push(`<p>${joined}</p>`);
paragraph = [];
};
const flushQuote = () => {
if (quote.length === 0) return;
const joined = quote
.map((l) => linkifyText(l))
.join("<br />");
out.push(`<blockquote>${joined}</blockquote>`);
quote = [];
};
for (const line of lines) {
if (/^\s*>+ ?/.test(line)) {
flushParagraph();
quote.push(line.replace(/^\s*>+ ?/, ""));
continue;
}
if (line.trim() === "") {
flushParagraph();
flushQuote();
continue;
}
flushQuote();
paragraph.push(line);
}
flushParagraph();
flushQuote();
return out.join("");
}
function renderBody(raw: string): { __html: string } {
if (!raw) return { __html: "" };
if (looksLikeHtml(raw)) {
return { __html: sanitizeHtml(raw) };
}
return { __html: renderPlainTextAsHtml(raw) };
}
export default function QuoteEmailCard({
quote,
attachments,
}: QuoteEmailCardProps) {
const hasBody = !!(quote.email_body && quote.email_body.trim());
const hasAttachments = attachments.length > 0;
// Hide the card entirely when there is no email data — manually-created
// quotes never have email data and shouldn't see an empty card.
if (!hasBody && !hasAttachments) return null;
const senderDisplay = quote.email_sender || "(unknown sender)";
// `email_received_at` is written by the v7 workflow from the IMAP date.
// Older quotes (and manually-created ones) don't have it — fall back to
// the quote's created timestamp so the field is never blank when we
// have any email data to show.
const receivedAt =
quote.email_received_at ||
quote.last_reply_at ||
quote.created ||
"";
const bodyHtml = renderBody(quote.email_body || "").__html;
return (
<Card data-size="default">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Mail className="h-4 w-4" />
Original Email
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Email-header strip — From / Received / Subject */}
<div className="rounded-md border bg-muted/30 p-3 space-y-2">
<div className="flex items-start gap-3">
<div
className="h-9 w-9 shrink-0 rounded-full bg-primary/10 text-primary flex items-center justify-center text-sm font-semibold"
aria-hidden="true"
>
{senderInitials(senderDisplay)}
</div>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-baseline gap-x-2">
<span className="font-semibold text-sm truncate" data-testid="email-sender">
{senderName(senderDisplay)}
</span>
<span className="text-xs text-muted-foreground truncate">
&lt;{senderDisplay}&gt;
</span>
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-muted-foreground mt-0.5">
<span className="inline-flex items-center gap-1">
<Calendar className="h-3 w-3" />
<span data-testid="email-received-at">
{formatReceivedAt(receivedAt)}
</span>
</span>
{quote.email_message_id && (
<span
className="inline-flex items-center gap-1 truncate max-w-[280px]"
title={quote.email_message_id}
>
<Inbox className="h-3 w-3" />
<span className="truncate">{quote.email_message_id}</span>
</span>
)}
</div>
</div>
</div>
{quote.email_subject && (
<div className="pt-2 border-t border-border/60">
<div className="text-xs text-muted-foreground">Subject</div>
<div
className="text-sm font-medium break-words"
data-testid="email-subject"
>
{quote.email_subject}
</div>
</div>
)}
</div>
{/* Email body — rendered as HTML. The workflow may store either
the raw multipart HTML (with inline styles) or the IMAP node's
`textAsHtml` conversion (clean <p>/<ul> tags); the sanitizer
strips dangerous tags and event handlers before injection. */}
{hasBody && (
<div>
<div className="text-xs text-muted-foreground mb-1">Message</div>
<div
className="rounded-md border bg-background p-4 max-h-[28rem] overflow-y-auto text-sm leading-relaxed
[&_p]:my-2 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0
[&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-6
[&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-6
[&_li]:my-0.5
[&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2 [&_a:hover]:no-underline
[&_img]:max-w-full [&_img]:h-auto [&_img]:rounded
[&_blockquote]:my-3 [&_blockquote]:pl-3 [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:text-muted-foreground [&_blockquote]:not-italic
[&_h1]:text-base [&_h1]:font-semibold [&_h1]:mt-3 [&_h1]:mb-1
[&_h2]:text-sm [&_h2]:font-semibold [&_h2]:mt-3 [&_h2]:mb-1
[&_h3]:text-sm [&_h3]:font-semibold [&_h3]:mt-2 [&_h3]:mb-1
[&_table]:w-full [&_table]:border-collapse [&_table]:my-2
[&_th]:border [&_th]:border-border [&_th]:px-2 [&_th]:py-1 [&_th]:bg-muted/40 [&_th]:text-left [&_th]:font-medium
[&_td]:border [&_td]:border-border [&_td]:px-2 [&_td]:py-1
[&_hr]:my-3 [&_hr]:border-border
[&_pre]:bg-muted [&_pre]:p-2 [&_pre]:rounded [&_pre]:overflow-x-auto [&_pre]:text-xs"
data-testid="email-body"
dangerouslySetInnerHTML={{ __html: bodyHtml }}
/>
</div>
)}
{/* Attachments — clickable rows that open an inline preview dialog. */}
{hasAttachments && (
<div>
<div className="text-xs text-muted-foreground mb-2 flex items-center gap-1">
<Paperclip className="h-3 w-3" />
Attachments ({attachments.length})
</div>
<ul className="space-y-1.5" data-testid="email-attachments">
{attachments.map((a) => (
<AttachmentRow key={a.id} attachment={a} />
))}
</ul>
</div>
)}
</CardContent>
</Card>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Attachment row + preview dialog
// ─────────────────────────────────────────────────────────────────────────────
/**
* Fetches the attachment file with the PB auth token in the Authorization
* header and returns a same-origin blob: URL the browser will happily feed
* into an `<iframe>` / `<img>`.
*
* Why blob URLs and not the PB file URL directly?
*
* The PB SDK gives us a URL of the form
* `{PB_BASE}/api/files/{collection}/{recordId}/{filename}?token=...`
* The browser's built-in PDF viewer (and `<iframe>`-embedded resources in
* general) does NOT forward the `?token=` query string to PB the way an
* `<a href download>` does, so the request comes back 401 and the iframe
* renders a black "loading…" page that never resolves. Fetching with the
* `Authorization` header in JS gets around that, and `URL.createObjectURL`
* hands us a blob: URL the iframe can render as same-origin content.
*
* The blob URL is revoked when the dialog closes (or the component
* unmounts) to avoid leaking memory.
*/
function useAttachmentBlob(attachment: QuoteAttachment, active: boolean): {
src: string | null;
loading: boolean;
error: string | null;
} {
const [src, setSrc] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!active) return;
const pb = getPocketBase();
const token = pb.authStore.token;
const fileUrl = attachmentUrl(attachment);
let cancelled = false;
let createdUrl: string | null = null;
// We deliberately drive `loading`/`error` from this effect because
// the fetch starts here — there's no parent that already knows
// whether the blob is ready, so the state has to originate here.
// eslint-disable-next-line react-hooks/set-state-in-effect
setLoading(true);
setError(null);
fetch(fileUrl, {
headers: token ? { Authorization: token } : undefined,
})
.then((res) => {
if (!res.ok) {
throw new Error(`HTTP ${res.status} ${res.statusText}`);
}
return res.blob();
})
.then((blob) => {
if (cancelled) {
// Component unmounted (or `active` flipped false) before we
// resolved — drop the blob without ever making a URL for it.
return;
}
const contentType = blob.type || attachment.content_type || "application/octet-stream";
const typed = blob.type ? blob : blob.slice(0, blob.size, contentType);
createdUrl = URL.createObjectURL(typed);
setSrc(createdUrl);
})
.catch((e) => {
if (cancelled) return;
setError(e instanceof Error ? e.message : "Failed to load preview");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
if (createdUrl) URL.revokeObjectURL(createdUrl);
setSrc((current) => {
if (current) URL.revokeObjectURL(current);
return null;
});
};
// We intentionally exclude `attachment` from deps — the dialog is
// mounted with a single attachment for its lifetime, and re-running
// this effect would refetch on every parent re-render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active]);
return { src, loading, error };
}
function AttachmentRow({ attachment }: { attachment: QuoteAttachment }) {
const [open, setOpen] = useState(false);
const kind = resolveKind(attachment);
const Icon = ATTACHMENT_ICONS[kind];
const url = attachmentUrl(attachment);
return (
<>
<li>
<button
type="button"
onClick={() => setOpen(true)}
className="w-full flex items-center gap-3 px-3 py-2 rounded-md border bg-card hover:bg-accent/30 transition-colors text-left cursor-pointer"
data-testid="attachment-row"
aria-label={`Preview ${attachment.filename}`}
>
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">
{attachment.filename}
</div>
<div className="text-xs text-muted-foreground">
{kind.toUpperCase()} · {formatBytes(attachment.size_bytes)}
{attachment.content_type ? ` · ${attachment.content_type}` : ""}
</div>
</div>
<a
href={url}
target="_blank"
rel="noopener noreferrer"
download={attachment.filename}
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline shrink-0"
data-testid="attachment-download"
>
<Download className="h-3 w-3" />
Download
</a>
</button>
</li>
<AttachmentPreviewDialog
attachment={attachment}
kind={kind}
url={url}
open={open}
onOpenChange={setOpen}
/>
</>
);
}
function AttachmentPreviewDialog({
attachment,
kind,
url,
open,
onOpenChange,
}: {
attachment: QuoteAttachment;
kind: QuoteAttachmentKind;
url: string;
open: boolean;
onOpenChange: (v: boolean) => void;
}) {
// PDFs and images get an inline preview. Anything else just shows
// metadata + a download button — better than pretending to render a
// .docx and ending up with a broken UI.
const canPreview = kind === "pdf" || kind === "image";
const Icon = ATTACHMENT_ICONS[kind];
// Lazily load the file as a blob once the dialog opens. `open` is the
// gate so we don't fetch every attachment on page load — only when the
// user actually wants to see it.
const { src: blobSrc, loading, error } = useAttachmentBlob(attachment, open && canPreview);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
className="sm:max-w-4xl w-[calc(100%-2rem)] max-h-[90vh] p-0 gap-0 overflow-hidden flex flex-col"
>
<div className="flex items-center gap-3 px-4 py-3 border-b">
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
<DialogTitle className="flex-1 min-w-0 truncate font-medium text-sm">
{attachment.filename}
</DialogTitle>
<Button
variant="ghost"
size="icon-sm"
nativeButton={false}
render={<a href={url} target="_blank" rel="noopener noreferrer" />}
aria-label="Open in new tab"
title="Open in new tab"
>
<ExternalLink className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
nativeButton={false}
render={
<a
href={url}
download={attachment.filename}
aria-label="Download"
title="Download"
/>
}
>
<Download className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => onOpenChange(false)}
aria-label="Close"
title="Close"
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 min-h-0 bg-muted/30">
{canPreview ? (
kind === "image" ? (
<div className="flex items-center justify-center p-4 min-h-[60vh] overflow-auto">
{blobSrc ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={blobSrc}
alt={attachment.filename}
className="max-w-full max-h-[80vh] object-contain rounded shadow-sm"
/>
) : loading ? (
<PreviewLoading label="Loading image…" />
) : (
<PreviewError
error={error}
filename={attachment.filename}
url={url}
/>
)}
</div>
) : (
<div className="w-full h-[80vh] bg-white">
{blobSrc ? (
// PDF preview via blob: URL. Embed is used in addition
// to iframe because some browsers' built-in PDF viewers
// attach to either tag, and having both gives us a
// fallback if one is blocked by an extension / CSP.
// The iframe is the primary; the embed is the fallback.
<iframe
src={blobSrc}
title={attachment.filename}
className="w-full h-full"
/>
) : loading ? (
<PreviewLoading label="Loading PDF…" />
) : (
<PreviewError
error={error}
filename={attachment.filename}
url={url}
/>
)}
</div>
)
) : (
<div className="p-8 flex flex-col items-center justify-center text-center gap-3 min-h-[40vh]">
<Icon className="h-12 w-12 text-muted-foreground" />
<div>
<p className="font-medium">{attachment.filename}</p>
<p className="text-sm text-muted-foreground mt-1">
{attachment.content_type || kind.toUpperCase()} ·{" "}
{formatBytes(attachment.size_bytes)}
</p>
<p className="text-xs text-muted-foreground mt-3">
No inline preview for this file type. Download to open it
locally.
</p>
</div>
<Button
variant="default"
nativeButton={false}
render={<a href={url} download={attachment.filename} />}
>
<Download className="mr-2 h-4 w-4" />
Download
</Button>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
function PreviewLoading({ label }: { label: string }) {
return (
<div className="flex flex-col items-center justify-center gap-2 h-full text-muted-foreground">
<div className="h-6 w-6 rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground animate-spin" />
<p className="text-sm">{label}</p>
</div>
);
}
function PreviewError({
error,
filename,
url,
}: {
error: string | null;
filename: string;
url: string;
}) {
return (
<div className="flex flex-col items-center justify-center gap-3 h-full text-center p-8">
<FileIcon className="h-10 w-10 text-muted-foreground" />
<div>
<p className="font-medium">{filename}</p>
<p className="text-sm text-muted-foreground mt-1">
Couldn&apos;t load preview{error ? ` — ${error}` : ""}.
</p>
<p className="text-xs text-muted-foreground mt-2">
Download the file to open it locally.
</p>
</div>
<Button variant="default" nativeButton={false} render={<a href={url} download={filename} />}>
<Download className="mr-2 h-4 w-4" />
Download
</Button>
</div>
);
}