TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1"use client";2import * as React from "react";3import { Braces, Download, FileCode2, FileText, Printer, Text } from "lucide-react";4import { Button, type ButtonProps } from "@/components/ui/button";5import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";6import { ActionSheet, type ActionSheetItem } from "@/components/ui/sheet";7import { Tooltip } from "@/components/ui/tooltip";8import { useIsMobile } from "@/lib/client/hooks";9import { EXPORT_OPTIONS, exportConversation, type ClientExportFormat } from "./export";1011export const EXPORT_ICONS: Record<ClientExportFormat, React.ReactNode> = {12 markdown: <FileText />,13 txt: <Text />,14 json: <Braces />,15 html: <FileCode2 />,16 pdf: <Printer />,17};1819export interface ExportMenuProps {20 conversationId: string;21 /** Shown as the sheet title on phones. */22 title?: string;23 /** Custom trigger; defaults to a ghost icon button with a tooltip. */24 children?: React.ReactNode;25 align?: "start" | "center" | "end";26 side?: "top" | "bottom" | "left" | "right";27 size?: ButtonProps["size"];28 variant?: ButtonProps["variant"];29 className?: string;30 onExported?: (format: ClientExportFormat) => void;31}3233/**34 * Export menu for a conversation — dropdown on desktop, ActionSheet on phones.35 * Used by the command palette; exported for the chat header (workstream A) and the sidebar.36 *37 * <ExportMenu conversationId={id} title={title} />38 * <ExportMenu conversationId={id}><Button variant="outline">Export</Button></ExportMenu>39 */40export function ExportMenu({ conversationId, title, children, align = "end", side, size = "icon-sm", variant = "ghost", className, onExported }: ExportMenuProps) {41 const isMobile = useIsMobile();42 const [open, setOpen] = React.useState(false);43 const [busy, setBusy] = React.useState<ClientExportFormat | null>(null);4445 const run = async (format: ClientExportFormat) => {46 setBusy(format);47 try {48 await exportConversation(conversationId, format);49 onExported?.(format);50 } catch {51 /* toasted */52 } finally {53 setBusy(null);54 }55 };5657 const trigger = children ?? (58 <Button variant={variant} size={size} className={className} aria-label="Export conversation" loading={busy !== null}>59 {busy === null ? <Download /> : null}60 </Button>61 );6263 if (isMobile) {64 const items: ActionSheetItem[] = EXPORT_OPTIONS.map((o) => ({ key: o.format, label: o.label, hint: o.hint, icon: EXPORT_ICONS[o.format], onSelect: () => void run(o.format) }));65 return (66 <>67 {React.isValidElement<{ onClick?: React.MouseEventHandler }>(trigger) ? React.cloneElement(trigger, { onClick: () => setOpen(true) }) : <span onClick={() => setOpen(true)}>{trigger}</span>}68 <ActionSheet open={open} onOpenChange={setOpen} title="Export conversation" description={title} items={items} />69 </>70 );71 }7273 return (74 <DropdownMenu>75 <Tooltip content={children ? undefined : "Export"}>76 <DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>77 </Tooltip>78 <DropdownMenuContent align={align} side={side} className="w-64">79 <DropdownMenuLabel className="px-2 py-1.5 text-[11px] font-medium uppercase tracking-wide text-fg-subtle">Export</DropdownMenuLabel>80 {EXPORT_OPTIONS.slice(0, 4).map((o) => (81 <DropdownMenuItem key={o.format} onSelect={() => void run(o.format)}>82 {EXPORT_ICONS[o.format]}83 <span className="flex-1">{o.label}</span>84 <span className="font-mono text-[11px] text-fg-subtle">{o.hint}</span>85 </DropdownMenuItem>86 ))}87 <DropdownMenuSeparator />88 <DropdownMenuItem onSelect={() => void run("pdf")}>89 {EXPORT_ICONS.pdf}90 <span className="flex-1">PDF (print)</span>91 <span className="font-mono text-[11px] text-fg-subtle">Print dialog</span>92 </DropdownMenuItem>93 </DropdownMenuContent>94 </DropdownMenu>95 );96}97