@melony/react 0.1.23 → 0.1.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -584,11 +584,22 @@ function Composer({
584
584
  className,
585
585
  options = [],
586
586
  autoFocus = false,
587
- defaultSelectedIds = []
587
+ defaultSelectedIds = [],
588
+ fileAttachments,
589
+ // Legacy props for backward compatibility
590
+ accept: legacyAccept,
591
+ maxFiles: legacyMaxFiles,
592
+ maxFileSize: legacyMaxFileSize
588
593
  }) {
594
+ const enabled = fileAttachments?.enabled !== false;
595
+ const accept = fileAttachments?.accept ?? legacyAccept;
596
+ const maxFiles = fileAttachments?.maxFiles ?? legacyMaxFiles ?? 10;
597
+ const maxFileSize = fileAttachments?.maxFileSize ?? legacyMaxFileSize ?? 10 * 1024 * 1024;
589
598
  const [selectedOptions, setSelectedOptions] = React10__namespace.default.useState(
590
599
  () => new Set(defaultSelectedIds)
591
600
  );
601
+ const [attachedFiles, setAttachedFiles] = React10__namespace.default.useState([]);
602
+ const fileInputRef = React10__namespace.default.useRef(null);
592
603
  const toggleOption = (id, groupOptions, type = "multiple") => {
593
604
  const next = new Set(selectedOptions);
594
605
  if (type === "single") {
@@ -608,7 +619,34 @@ function Composer({
608
619
  }
609
620
  setSelectedOptions(next);
610
621
  };
611
- const handleInternalSubmit = () => {
622
+ const handleFileSelect = (e) => {
623
+ const files = Array.from(e.target.files || []);
624
+ const validFiles = files.filter((file) => {
625
+ if (file.size > maxFileSize) {
626
+ console.warn(`File ${file.name} exceeds maximum size of ${maxFileSize} bytes`);
627
+ return false;
628
+ }
629
+ return true;
630
+ });
631
+ const remainingSlots = maxFiles - attachedFiles.length;
632
+ const filesToAdd = validFiles.slice(0, remainingSlots);
633
+ if (filesToAdd.length < validFiles.length) {
634
+ console.warn(`Only ${filesToAdd.length} files can be added (max: ${maxFiles})`);
635
+ }
636
+ setAttachedFiles((prev) => [...prev, ...filesToAdd]);
637
+ if (fileInputRef.current) {
638
+ fileInputRef.current.value = "";
639
+ }
640
+ };
641
+ const handleRemoveFile = (index) => {
642
+ setAttachedFiles((prev) => prev.filter((_, i) => i !== index));
643
+ };
644
+ const formatFileSize = (bytes) => {
645
+ if (bytes < 1024) return bytes + " B";
646
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
647
+ return (bytes / (1024 * 1024)).toFixed(1) + " MB";
648
+ };
649
+ const handleInternalSubmit = async () => {
612
650
  const state = {};
613
651
  options.forEach((group) => {
614
652
  const selectedInGroup = group.options.filter(
@@ -625,85 +663,178 @@ function Composer({
625
663
  }
626
664
  }
627
665
  });
666
+ if (attachedFiles.length > 0) {
667
+ const filePromises = attachedFiles.map((file) => {
668
+ return new Promise((resolve, reject) => {
669
+ const reader = new FileReader();
670
+ reader.onload = () => {
671
+ try {
672
+ const base64 = reader.result;
673
+ if (!base64) {
674
+ reject(new Error("FileReader returned empty result"));
675
+ return;
676
+ }
677
+ const base64Data = base64.includes(",") ? base64.split(",")[1] : base64;
678
+ resolve({
679
+ name: file.name,
680
+ type: file.type,
681
+ size: file.size,
682
+ data: base64Data
683
+ });
684
+ } catch (error) {
685
+ reject(error);
686
+ }
687
+ };
688
+ reader.onerror = (error) => {
689
+ reject(new Error(`Failed to read file ${file.name}: ${error}`));
690
+ };
691
+ reader.onabort = () => {
692
+ reject(new Error(`File read aborted for ${file.name}`));
693
+ };
694
+ reader.readAsDataURL(file);
695
+ });
696
+ });
697
+ try {
698
+ const convertedFiles = await Promise.all(filePromises);
699
+ if (convertedFiles.length > 0) {
700
+ state.files = convertedFiles;
701
+ }
702
+ } catch (error) {
703
+ console.error("Failed to convert files to base64:", error);
704
+ }
705
+ }
628
706
  onSubmit(state);
707
+ setAttachedFiles([]);
629
708
  };
630
709
  const handleKeyDown = (e) => {
631
710
  if (e.key === "Enter" && !e.shiftKey) {
632
711
  e.preventDefault();
633
- handleInternalSubmit();
712
+ handleInternalSubmit().catch(console.error);
634
713
  }
635
714
  };
636
- return /* @__PURE__ */ jsxRuntime.jsx("div", { className: cn("relative flex flex-col w-full", className), children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "relative flex flex-col w-full border-input border-[1.5px] rounded-3xl bg-background shadow-sm focus-within:border-ring transition-all p-2", children: [
637
- /* @__PURE__ */ jsxRuntime.jsx(
638
- Textarea,
715
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: cn("relative flex flex-col w-full", className), children: [
716
+ enabled && attachedFiles.length > 0 && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "mb-2 flex flex-wrap gap-2", children: attachedFiles.map((file, index) => /* @__PURE__ */ jsxRuntime.jsxs(
717
+ "div",
639
718
  {
640
- value,
641
- onChange: (e) => onChange(e.target.value),
642
- onKeyDown: handleKeyDown,
643
- placeholder,
644
- className: "min-h-[44px] max-h-[200px] border-none bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 px-3 py-2 text-[15px] resize-none",
645
- autoFocus
646
- }
647
- ),
648
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex justify-between items-center px-1", children: [
649
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex items-center gap-1", children: options.map((group) => {
650
- const selectedInGroup = group.options.filter(
651
- (o) => selectedOptions.has(o.id)
652
- );
653
- const label = selectedInGroup.length === 0 ? group.label : selectedInGroup.length === 1 ? selectedInGroup[0].label : `${group.label} (${selectedInGroup.length})`;
654
- const isSingle = group.type === "single";
655
- return /* @__PURE__ */ jsxRuntime.jsxs(DropdownMenu, { children: [
719
+ className: "flex items-center gap-2 px-3 py-1.5 bg-muted rounded-lg text-sm",
720
+ children: [
721
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "truncate max-w-[200px]", title: file.name, children: file.name }),
722
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-muted-foreground text-xs", children: formatFileSize(file.size) }),
656
723
  /* @__PURE__ */ jsxRuntime.jsx(
657
- DropdownMenuTrigger,
724
+ "button",
658
725
  {
659
- render: /* @__PURE__ */ jsxRuntime.jsxs(
660
- Button,
661
- {
662
- variant: "ghost",
663
- size: "sm",
664
- className: cn(
665
- selectedInGroup.length > 0 ? "text-foreground bg-muted/50" : "text-muted-foreground"
666
- ),
667
- children: [
668
- label,
669
- /* @__PURE__ */ jsxRuntime.jsx(ICONS.IconChevronDown, { className: "h-3 w-3 opacity-50" })
670
- ]
671
- }
672
- )
726
+ type: "button",
727
+ onClick: () => handleRemoveFile(index),
728
+ className: "ml-1 hover:bg-muted-foreground/20 rounded p-0.5 transition-colors",
729
+ "aria-label": "Remove file",
730
+ children: /* @__PURE__ */ jsxRuntime.jsx(ICONS.IconX, { className: "h-3.5 w-3.5" })
673
731
  }
674
- ),
675
- /* @__PURE__ */ jsxRuntime.jsx(DropdownMenuContent, { align: "start", className: "w-56", children: /* @__PURE__ */ jsxRuntime.jsxs(DropdownMenuGroup, { children: [
676
- /* @__PURE__ */ jsxRuntime.jsx(DropdownMenuLabel, { children: group.label }),
677
- /* @__PURE__ */ jsxRuntime.jsx(DropdownMenuSeparator, {}),
678
- group.options.map((option) => /* @__PURE__ */ jsxRuntime.jsx(
679
- DropdownMenuCheckboxItem,
680
- {
681
- checked: selectedOptions.has(option.id),
682
- onCheckedChange: () => toggleOption(
683
- option.id,
684
- group.options,
685
- isSingle ? "single" : "multiple"
686
- ),
687
- onSelect: (e) => e.preventDefault(),
688
- children: option.label
689
- },
690
- option.id
691
- ))
692
- ] }) })
693
- ] }, group.id);
694
- }) }),
732
+ )
733
+ ]
734
+ },
735
+ index
736
+ )) }),
737
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "relative flex flex-col w-full border-input border-[1.5px] rounded-3xl bg-background shadow-sm focus-within:border-ring transition-all p-2", children: [
695
738
  /* @__PURE__ */ jsxRuntime.jsx(
696
- Button,
739
+ Textarea,
697
740
  {
698
- type: "submit",
699
- disabled: !value.trim() && !isLoading || isLoading,
700
- size: "icon-lg",
701
- onClick: handleInternalSubmit,
702
- children: isLoading ? /* @__PURE__ */ jsxRuntime.jsx(ICONS.IconLoader2, { className: "h-5 w-5 animate-spin" }) : /* @__PURE__ */ jsxRuntime.jsx(ICONS.IconArrowUp, { className: "h-5 w-5" })
741
+ value,
742
+ onChange: (e) => onChange(e.target.value),
743
+ onKeyDown: handleKeyDown,
744
+ placeholder,
745
+ className: "min-h-[44px] max-h-[200px] border-none bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 px-3 py-2 text-[15px] resize-none",
746
+ autoFocus
703
747
  }
704
- )
748
+ ),
749
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex justify-between items-center px-1", children: [
750
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-1", children: [
751
+ enabled && /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
752
+ /* @__PURE__ */ jsxRuntime.jsx(
753
+ "input",
754
+ {
755
+ ref: fileInputRef,
756
+ type: "file",
757
+ multiple: true,
758
+ accept,
759
+ onChange: handleFileSelect,
760
+ className: "hidden",
761
+ disabled: isLoading || attachedFiles.length >= maxFiles
762
+ }
763
+ ),
764
+ /* @__PURE__ */ jsxRuntime.jsx(
765
+ Button,
766
+ {
767
+ type: "button",
768
+ variant: "ghost",
769
+ size: "sm",
770
+ onClick: () => fileInputRef.current?.click(),
771
+ disabled: isLoading || attachedFiles.length >= maxFiles,
772
+ className: "text-muted-foreground",
773
+ title: attachedFiles.length >= maxFiles ? `Maximum ${maxFiles} files allowed` : "Attach file",
774
+ children: /* @__PURE__ */ jsxRuntime.jsx(ICONS.IconPaperclip, { className: "h-4 w-4" })
775
+ }
776
+ )
777
+ ] }),
778
+ options.map((group) => {
779
+ const selectedInGroup = group.options.filter(
780
+ (o) => selectedOptions.has(o.id)
781
+ );
782
+ const label = selectedInGroup.length === 0 ? group.label : selectedInGroup.length === 1 ? selectedInGroup[0].label : `${group.label} (${selectedInGroup.length})`;
783
+ const isSingle = group.type === "single";
784
+ return /* @__PURE__ */ jsxRuntime.jsxs(DropdownMenu, { children: [
785
+ /* @__PURE__ */ jsxRuntime.jsx(
786
+ DropdownMenuTrigger,
787
+ {
788
+ render: /* @__PURE__ */ jsxRuntime.jsxs(
789
+ Button,
790
+ {
791
+ variant: "ghost",
792
+ size: "sm",
793
+ className: cn(
794
+ selectedInGroup.length > 0 ? "text-foreground bg-muted/50" : "text-muted-foreground"
795
+ ),
796
+ children: [
797
+ label,
798
+ /* @__PURE__ */ jsxRuntime.jsx(ICONS.IconChevronDown, { className: "h-3 w-3 opacity-50" })
799
+ ]
800
+ }
801
+ )
802
+ }
803
+ ),
804
+ /* @__PURE__ */ jsxRuntime.jsx(DropdownMenuContent, { align: "start", className: "w-56", children: /* @__PURE__ */ jsxRuntime.jsxs(DropdownMenuGroup, { children: [
805
+ /* @__PURE__ */ jsxRuntime.jsx(DropdownMenuLabel, { children: group.label }),
806
+ /* @__PURE__ */ jsxRuntime.jsx(DropdownMenuSeparator, {}),
807
+ group.options.map((option) => /* @__PURE__ */ jsxRuntime.jsx(
808
+ DropdownMenuCheckboxItem,
809
+ {
810
+ checked: selectedOptions.has(option.id),
811
+ onCheckedChange: () => toggleOption(
812
+ option.id,
813
+ group.options,
814
+ isSingle ? "single" : "multiple"
815
+ ),
816
+ onSelect: (e) => e.preventDefault(),
817
+ children: option.label
818
+ },
819
+ option.id
820
+ ))
821
+ ] }) })
822
+ ] }, group.id);
823
+ })
824
+ ] }),
825
+ /* @__PURE__ */ jsxRuntime.jsx(
826
+ Button,
827
+ {
828
+ type: "submit",
829
+ disabled: !value.trim() && attachedFiles.length === 0 && !isLoading || isLoading,
830
+ size: "icon-lg",
831
+ onClick: () => handleInternalSubmit().catch(console.error),
832
+ children: isLoading ? /* @__PURE__ */ jsxRuntime.jsx(ICONS.IconLoader2, { className: "h-5 w-5 animate-spin" }) : /* @__PURE__ */ jsxRuntime.jsx(ICONS.IconArrowUp, { className: "h-5 w-5" })
833
+ }
834
+ )
835
+ ] })
705
836
  ] })
706
- ] }) });
837
+ ] });
707
838
  }
708
839
  function Card({
709
840
  className,
@@ -2377,13 +2508,14 @@ function Thread({
2377
2508
  }, [messages]);
2378
2509
  const handleSubmit = async (state, overrideInput) => {
2379
2510
  const text = (overrideInput ?? input).trim();
2380
- if (!text || isLoading) return;
2511
+ const hasFiles = state?.files && Array.isArray(state.files) && state.files.length > 0;
2512
+ if (!text && !hasFiles || isLoading) return;
2381
2513
  if (!overrideInput) setInput("");
2382
2514
  await sendEvent(
2383
2515
  {
2384
2516
  type: "text",
2385
2517
  role: "user",
2386
- data: { content: text }
2518
+ data: { content: text || "" }
2387
2519
  },
2388
2520
  { state: { ...state, threadId: activeThreadId ?? void 0 } }
2389
2521
  );
@@ -2441,7 +2573,8 @@ function Thread({
2441
2573
  isLoading,
2442
2574
  options,
2443
2575
  autoFocus,
2444
- defaultSelectedIds: allDefaultSelectedIds
2576
+ defaultSelectedIds: allDefaultSelectedIds,
2577
+ fileAttachments: config?.fileAttachments
2445
2578
  }
2446
2579
  ) }) })
2447
2580
  ]