love-ui 1.2.12 → 1.2.14
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.js +8 -7
- package/dist/mcp-server.js +1 -1
- package/package.json +3 -2
- package/packages/loveui-skills/SKILL.md +99 -0
- package/packages/loveui-skills/agents/openai.yaml +11 -0
- package/packages/loveui-skills/references/design-directions.md +60 -0
- package/packages/loveui-skills/references/mcp-catalog-workflow.md +68 -0
- package/packages/loveui-skills/references/page-blueprints.md +76 -0
- package/packages/loveui-skills/references/quality-gates.md +51 -0
- package/public/r/accordion-controlled.json +1 -1
- package/public/r/accordion-demo.json +1 -1
- package/public/r/accordion-multiple.json +1 -1
- package/public/r/accordion-single.json +1 -1
- package/public/r/alert-demo.json +1 -1
- package/public/r/alert-dialog-demo.json +1 -1
- package/public/r/alert-error.json +1 -1
- package/public/r/alert-info.json +1 -1
- package/public/r/alert-success.json +1 -1
- package/public/r/alert-warning.json +1 -1
- package/public/r/alert-with-icon-action.json +1 -1
- package/public/r/alert-with-icon.json +1 -1
- package/public/r/announcement-tagless.json +1 -1
- package/public/r/announcement-themes.json +1 -1
- package/public/r/announcement.json +1 -1
- package/public/r/autocomplete-async.json +1 -1
- package/public/r/autocomplete-autohighlight.json +1 -1
- package/public/r/autocomplete-demo.json +1 -1
- package/public/r/autocomplete-disabled.json +1 -1
- package/public/r/autocomplete-form.json +1 -1
- package/public/r/autocomplete-grouped.json +1 -1
- package/public/r/autocomplete-inline.json +1 -1
- package/public/r/autocomplete-lg.json +1 -1
- package/public/r/autocomplete-limit.json +1 -1
- package/public/r/autocomplete-sm.json +1 -1
- package/public/r/autocomplete-with-clear.json +1 -1
- package/public/r/autocomplete-with-label.json +1 -1
- package/public/r/autocomplete-with-trigger-clear.json +1 -1
- package/public/r/avatar-demo.json +1 -1
- package/public/r/avatar-fallback.json +1 -1
- package/public/r/avatar-group.json +1 -1
- package/public/r/avatar-radius.json +1 -1
- package/public/r/avatar-size.json +1 -1
- package/public/r/avatar-stack-hover.json +1 -1
- package/public/r/avatar-stack.json +1 -1
- package/public/r/badge-demo.json +1 -1
- package/public/r/badge-destructive.json +1 -1
- package/public/r/badge-error.json +1 -1
- package/public/r/badge-info.json +1 -1
- package/public/r/badge-lg.json +1 -1
- package/public/r/badge-outline.json +1 -1
- package/public/r/badge-secondary.json +1 -1
- package/public/r/badge-sm.json +1 -1
- package/public/r/badge-success.json +1 -1
- package/public/r/badge-warning.json +1 -1
- package/public/r/badge-with-icon.json +1 -1
- package/public/r/badge-with-link.json +1 -1
- package/public/r/banner-inset.json +1 -1
- package/public/r/banner-themes.json +1 -1
- package/public/r/banner.json +1 -1
- package/public/r/bookmarks.json +206 -0
- package/public/r/breadcrumb-custom-separator.json +1 -1
- package/public/r/breadcrumb-demo.json +1 -1
- package/public/r/button-demo.json +1 -1
- package/public/r/button-destructive-outline.json +1 -1
- package/public/r/button-destructive.json +1 -1
- package/public/r/button-disabled.json +1 -1
- package/public/r/button-ghost.json +1 -1
- package/public/r/button-lg.json +1 -1
- package/public/r/button-link.json +1 -1
- package/public/r/button-loading.json +1 -1
- package/public/r/button-outline.json +1 -1
- package/public/r/button-secondary.json +1 -1
- package/public/r/button-sm.json +1 -1
- package/public/r/button-with-icon.json +1 -1
- package/public/r/button-with-link.json +1 -1
- package/public/r/button-xl.json +1 -1
- package/public/r/button-xs.json +1 -1
- package/public/r/calendar-headless.json +1 -1
- package/public/r/calendar-rac.json +16 -0
- package/public/r/calendar.json +1 -1
- package/public/r/card-demo.json +1 -1
- package/public/r/chart.json +15 -0
- package/public/r/checkbox-card.json +1 -1
- package/public/r/checkbox-demo.json +1 -1
- package/public/r/checkbox-disabled.json +1 -1
- package/public/r/checkbox-form.json +1 -1
- package/public/r/checkbox-group-demo.json +1 -1
- package/public/r/checkbox-group-disabled.json +1 -1
- package/public/r/checkbox-group-form.json +1 -1
- package/public/r/checkbox-group-nested-parent.json +1 -1
- package/public/r/checkbox-group-parent.json +1 -1
- package/public/r/checkbox-tree.json +12 -0
- package/public/r/checkbox-with-description.json +1 -1
- package/public/r/choicebox-inline.json +1 -1
- package/public/r/choicebox.json +1 -1
- package/public/r/codebase.json +1 -1
- package/public/r/collaborative-canvas.json +1 -1
- package/public/r/collapsible-demo.json +1 -1
- package/public/r/combobox-autohighlight.json +1 -1
- package/public/r/combobox-controlled.json +1 -1
- package/public/r/combobox-create-new.json +1 -1
- package/public/r/combobox-demo.json +1 -1
- package/public/r/combobox-disabled.json +1 -1
- package/public/r/combobox-fixed-width.json +1 -1
- package/public/r/combobox-form.json +1 -1
- package/public/r/combobox-grouped.json +1 -1
- package/public/r/combobox-lg.json +1 -1
- package/public/r/combobox-multiple-form.json +1 -1
- package/public/r/combobox-multiple.json +1 -1
- package/public/r/combobox-sm.json +1 -1
- package/public/r/combobox-with-clear.json +1 -1
- package/public/r/combobox-with-inner-input.json +1 -1
- package/public/r/combobox-with-label.json +1 -1
- package/public/r/command.json +18 -0
- package/public/r/comp-01.json +22 -0
- package/public/r/comp-02.json +23 -0
- package/public/r/comp-03.json +23 -0
- package/public/r/comp-04.json +23 -0
- package/public/r/comp-05.json +22 -0
- package/public/r/comp-06.json +23 -0
- package/public/r/comp-07.json +22 -0
- package/public/r/comp-08.json +23 -0
- package/public/r/comp-09.json +22 -0
- package/public/r/comp-10.json +22 -0
- package/public/r/comp-100.json +23 -0
- package/public/r/comp-101.json +23 -0
- package/public/r/comp-102.json +23 -0
- package/public/r/comp-103.json +23 -0
- package/public/r/comp-104.json +23 -0
- package/public/r/comp-105.json +23 -0
- package/public/r/comp-106.json +22 -0
- package/public/r/comp-107.json +22 -0
- package/public/r/comp-108.json +23 -0
- package/public/r/comp-109.json +22 -0
- package/public/r/comp-11.json +22 -0
- package/public/r/comp-110.json +22 -0
- package/public/r/comp-111.json +21 -0
- package/public/r/comp-112.json +21 -0
- package/public/r/comp-113.json +22 -0
- package/public/r/comp-114.json +23 -0
- package/public/r/comp-115.json +22 -0
- package/public/r/comp-116.json +22 -0
- package/public/r/comp-117.json +23 -0
- package/public/r/comp-118.json +23 -0
- package/public/r/comp-119.json +27 -0
- package/public/r/comp-12.json +22 -0
- package/public/r/comp-120.json +27 -0
- package/public/r/comp-121.json +27 -0
- package/public/r/comp-122.json +27 -0
- package/public/r/comp-123.json +22 -0
- package/public/r/comp-124.json +22 -0
- package/public/r/comp-125.json +31 -0
- package/public/r/comp-126.json +31 -0
- package/public/r/comp-127.json +21 -0
- package/public/r/comp-128.json +21 -0
- package/public/r/comp-129.json +24 -0
- package/public/r/comp-13.json +22 -0
- package/public/r/comp-130.json +23 -0
- package/public/r/comp-131.json +23 -0
- package/public/r/comp-132.json +23 -0
- package/public/r/comp-133.json +23 -0
- package/public/r/comp-134.json +23 -0
- package/public/r/comp-135.json +24 -0
- package/public/r/comp-136.json +23 -0
- package/public/r/comp-137.json +23 -0
- package/public/r/comp-138.json +25 -0
- package/public/r/comp-139.json +23 -0
- package/public/r/comp-14.json +22 -0
- package/public/r/comp-140.json +23 -0
- package/public/r/comp-141.json +23 -0
- package/public/r/comp-142.json +25 -0
- package/public/r/comp-143.json +23 -0
- package/public/r/comp-144.json +24 -0
- package/public/r/comp-145.json +24 -0
- package/public/r/comp-146.json +24 -0
- package/public/r/comp-147.json +24 -0
- package/public/r/comp-148.json +25 -0
- package/public/r/comp-149.json +24 -0
- package/public/r/comp-15.json +22 -0
- package/public/r/comp-150.json +21 -0
- package/public/r/comp-151.json +23 -0
- package/public/r/comp-152.json +23 -0
- package/public/r/comp-153.json +23 -0
- package/public/r/comp-154.json +23 -0
- package/public/r/comp-155.json +23 -0
- package/public/r/comp-156.json +25 -0
- package/public/r/comp-157.json +27 -0
- package/public/r/comp-158.json +24 -0
- package/public/r/comp-159.json +24 -0
- package/public/r/comp-16.json +22 -0
- package/public/r/comp-160.json +24 -0
- package/public/r/comp-161.json +24 -0
- package/public/r/comp-162.json +24 -0
- package/public/r/comp-163.json +28 -0
- package/public/r/comp-164.json +24 -0
- package/public/r/comp-165.json +24 -0
- package/public/r/comp-166.json +25 -0
- package/public/r/comp-167.json +24 -0
- package/public/r/comp-168.json +24 -0
- package/public/r/comp-169.json +23 -0
- package/public/r/comp-17.json +25 -0
- package/public/r/comp-170.json +25 -0
- package/public/r/comp-171.json +28 -0
- package/public/r/comp-172.json +23 -0
- package/public/r/comp-173.json +23 -0
- package/public/r/comp-174.json +23 -0
- package/public/r/comp-175.json +23 -0
- package/public/r/comp-176.json +23 -0
- package/public/r/comp-177.json +23 -0
- package/public/r/comp-178.json +23 -0
- package/public/r/comp-179.json +24 -0
- package/public/r/comp-18.json +25 -0
- package/public/r/comp-180.json +23 -0
- package/public/r/comp-181.json +25 -0
- package/public/r/comp-182.json +24 -0
- package/public/r/comp-183.json +25 -0
- package/public/r/comp-184.json +25 -0
- package/public/r/comp-185.json +24 -0
- package/public/r/comp-186.json +25 -0
- package/public/r/comp-187.json +25 -0
- package/public/r/comp-188.json +25 -0
- package/public/r/comp-189.json +23 -0
- package/public/r/comp-19.json +23 -0
- package/public/r/comp-190.json +23 -0
- package/public/r/comp-191.json +24 -0
- package/public/r/comp-192.json +23 -0
- package/public/r/comp-193.json +23 -0
- package/public/r/comp-194.json +24 -0
- package/public/r/comp-195.json +23 -0
- package/public/r/comp-196.json +24 -0
- package/public/r/comp-197.json +23 -0
- package/public/r/comp-198.json +23 -0
- package/public/r/comp-199.json +23 -0
- package/public/r/comp-20.json +23 -0
- package/public/r/comp-200.json +25 -0
- package/public/r/comp-201.json +22 -0
- package/public/r/comp-202.json +22 -0
- package/public/r/comp-203.json +23 -0
- package/public/r/comp-204.json +23 -0
- package/public/r/comp-205.json +23 -0
- package/public/r/comp-206.json +24 -0
- package/public/r/comp-207.json +23 -0
- package/public/r/comp-208.json +23 -0
- package/public/r/comp-209.json +23 -0
- package/public/r/comp-21.json +23 -0
- package/public/r/comp-210.json +24 -0
- package/public/r/comp-211.json +24 -0
- package/public/r/comp-212.json +23 -0
- package/public/r/comp-213.json +23 -0
- package/public/r/comp-214.json +23 -0
- package/public/r/comp-215.json +24 -0
- package/public/r/comp-216.json +22 -0
- package/public/r/comp-217.json +22 -0
- package/public/r/comp-218.json +25 -0
- package/public/r/comp-219.json +23 -0
- package/public/r/comp-22.json +23 -0
- package/public/r/comp-220.json +24 -0
- package/public/r/comp-221.json +23 -0
- package/public/r/comp-222.json +26 -0
- package/public/r/comp-223.json +26 -0
- package/public/r/comp-224.json +23 -0
- package/public/r/comp-225.json +24 -0
- package/public/r/comp-226.json +26 -0
- package/public/r/comp-227.json +26 -0
- package/public/r/comp-228.json +26 -0
- package/public/r/comp-229.json +30 -0
- package/public/r/comp-23.json +24 -0
- package/public/r/comp-230.json +30 -0
- package/public/r/comp-231.json +32 -0
- package/public/r/comp-232.json +31 -0
- package/public/r/comp-233.json +30 -0
- package/public/r/comp-234.json +28 -0
- package/public/r/comp-235.json +28 -0
- package/public/r/comp-236.json +24 -0
- package/public/r/comp-237.json +26 -0
- package/public/r/comp-238.json +26 -0
- package/public/r/comp-239.json +26 -0
- package/public/r/comp-24.json +23 -0
- package/public/r/comp-240.json +23 -0
- package/public/r/comp-241.json +24 -0
- package/public/r/comp-242.json +23 -0
- package/public/r/comp-243.json +23 -0
- package/public/r/comp-244.json +23 -0
- package/public/r/comp-245.json +23 -0
- package/public/r/comp-246.json +23 -0
- package/public/r/comp-247.json +23 -0
- package/public/r/comp-248.json +23 -0
- package/public/r/comp-249.json +24 -0
- package/public/r/comp-25.json +24 -0
- package/public/r/comp-250.json +25 -0
- package/public/r/comp-251.json +25 -0
- package/public/r/comp-252.json +25 -0
- package/public/r/comp-253.json +25 -0
- package/public/r/comp-254.json +35 -0
- package/public/r/comp-255.json +30 -0
- package/public/r/comp-256.json +25 -0
- package/public/r/comp-257.json +25 -0
- package/public/r/comp-258.json +32 -0
- package/public/r/comp-259.json +26 -0
- package/public/r/comp-26.json +24 -0
- package/public/r/comp-260.json +26 -0
- package/public/r/comp-261.json +24 -0
- package/public/r/comp-262.json +31 -0
- package/public/r/comp-263.json +25 -0
- package/public/r/comp-264.json +33 -0
- package/public/r/comp-265.json +32 -0
- package/public/r/comp-266.json +24 -0
- package/public/r/comp-267.json +19 -0
- package/public/r/comp-268.json +19 -0
- package/public/r/comp-269.json +19 -0
- package/public/r/comp-27.json +24 -0
- package/public/r/comp-270.json +19 -0
- package/public/r/comp-271.json +19 -0
- package/public/r/comp-272.json +19 -0
- package/public/r/comp-273.json +19 -0
- package/public/r/comp-274.json +19 -0
- package/public/r/comp-275.json +19 -0
- package/public/r/comp-276.json +19 -0
- package/public/r/comp-277.json +21 -0
- package/public/r/comp-278.json +21 -0
- package/public/r/comp-279.json +23 -0
- package/public/r/comp-28.json +24 -0
- package/public/r/comp-280.json +23 -0
- package/public/r/comp-281.json +23 -0
- package/public/r/comp-282.json +23 -0
- package/public/r/comp-283.json +23 -0
- package/public/r/comp-284.json +23 -0
- package/public/r/comp-285.json +23 -0
- package/public/r/comp-286.json +23 -0
- package/public/r/comp-287.json +24 -0
- package/public/r/comp-288.json +23 -0
- package/public/r/comp-289.json +24 -0
- package/public/r/comp-29.json +24 -0
- package/public/r/comp-290.json +24 -0
- package/public/r/comp-291.json +24 -0
- package/public/r/comp-292.json +24 -0
- package/public/r/comp-293.json +26 -0
- package/public/r/comp-294.json +24 -0
- package/public/r/comp-295.json +23 -0
- package/public/r/comp-296.json +23 -0
- package/public/r/comp-297.json +30 -0
- package/public/r/comp-298.json +26 -0
- package/public/r/comp-299.json +28 -0
- package/public/r/comp-30.json +23 -0
- package/public/r/comp-300.json +29 -0
- package/public/r/comp-301.json +24 -0
- package/public/r/comp-302.json +18 -0
- package/public/r/comp-303.json +18 -0
- package/public/r/comp-304.json +18 -0
- package/public/r/comp-305.json +21 -0
- package/public/r/comp-306.json +21 -0
- package/public/r/comp-307.json +21 -0
- package/public/r/comp-308.json +21 -0
- package/public/r/comp-309.json +21 -0
- package/public/r/comp-31.json +21 -0
- package/public/r/comp-310.json +23 -0
- package/public/r/comp-311.json +20 -0
- package/public/r/comp-312.json +21 -0
- package/public/r/comp-313.json +26 -0
- package/public/r/comp-314.json +26 -0
- package/public/r/comp-315.json +24 -0
- package/public/r/comp-316.json +25 -0
- package/public/r/comp-317.json +24 -0
- package/public/r/comp-318.json +24 -0
- package/public/r/comp-319.json +24 -0
- package/public/r/comp-32.json +21 -0
- package/public/r/comp-320.json +27 -0
- package/public/r/comp-321.json +28 -0
- package/public/r/comp-322.json +27 -0
- package/public/r/comp-323.json +30 -0
- package/public/r/comp-324.json +28 -0
- package/public/r/comp-325.json +29 -0
- package/public/r/comp-326.json +30 -0
- package/public/r/comp-327.json +30 -0
- package/public/r/comp-328.json +37 -0
- package/public/r/comp-329.json +38 -0
- package/public/r/comp-33.json +18 -0
- package/public/r/comp-330.json +27 -0
- package/public/r/comp-331.json +42 -0
- package/public/r/comp-332.json +25 -0
- package/public/r/comp-333.json +29 -0
- package/public/r/comp-334.json +22 -0
- package/public/r/comp-335.json +22 -0
- package/public/r/comp-336.json +22 -0
- package/public/r/comp-337.json +22 -0
- package/public/r/comp-338.json +22 -0
- package/public/r/comp-339.json +22 -0
- package/public/r/comp-34.json +27 -0
- package/public/r/comp-340.json +22 -0
- package/public/r/comp-341.json +22 -0
- package/public/r/comp-342.json +22 -0
- package/public/r/comp-343.json +22 -0
- package/public/r/comp-344.json +22 -0
- package/public/r/comp-345.json +22 -0
- package/public/r/comp-346.json +22 -0
- package/public/r/comp-347.json +22 -0
- package/public/r/comp-348.json +22 -0
- package/public/r/comp-349.json +22 -0
- package/public/r/comp-35.json +27 -0
- package/public/r/comp-350.json +22 -0
- package/public/r/comp-351.json +22 -0
- package/public/r/comp-352.json +24 -0
- package/public/r/comp-353.json +24 -0
- package/public/r/comp-354.json +23 -0
- package/public/r/comp-355.json +23 -0
- package/public/r/comp-356.json +23 -0
- package/public/r/comp-357.json +23 -0
- package/public/r/comp-358.json +23 -0
- package/public/r/comp-359.json +24 -0
- package/public/r/comp-36.json +26 -0
- package/public/r/comp-360.json +25 -0
- package/public/r/comp-361.json +23 -0
- package/public/r/comp-362.json +24 -0
- package/public/r/comp-363.json +27 -0
- package/public/r/comp-364.json +26 -0
- package/public/r/comp-365.json +24 -0
- package/public/r/comp-366.json +23 -0
- package/public/r/comp-367.json +23 -0
- package/public/r/comp-368.json +23 -0
- package/public/r/comp-369.json +23 -0
- package/public/r/comp-37.json +23 -0
- package/public/r/comp-370.json +23 -0
- package/public/r/comp-371.json +24 -0
- package/public/r/comp-372.json +24 -0
- package/public/r/comp-373.json +25 -0
- package/public/r/comp-374.json +26 -0
- package/public/r/comp-375.json +25 -0
- package/public/r/comp-376.json +26 -0
- package/public/r/comp-377.json +27 -0
- package/public/r/comp-378.json +24 -0
- package/public/r/comp-379.json +24 -0
- package/public/r/comp-38.json +23 -0
- package/public/r/comp-380.json +24 -0
- package/public/r/comp-381.json +26 -0
- package/public/r/comp-382.json +25 -0
- package/public/r/comp-383.json +26 -0
- package/public/r/comp-384.json +24 -0
- package/public/r/comp-385.json +24 -0
- package/public/r/comp-386.json +24 -0
- package/public/r/comp-387.json +28 -0
- package/public/r/comp-388.json +26 -0
- package/public/r/comp-389.json +24 -0
- package/public/r/comp-39.json +23 -0
- package/public/r/comp-390.json +23 -0
- package/public/r/comp-391.json +23 -0
- package/public/r/comp-392.json +23 -0
- package/public/r/comp-393.json +23 -0
- package/public/r/comp-394.json +24 -0
- package/public/r/comp-395.json +24 -0
- package/public/r/comp-396.json +24 -0
- package/public/r/comp-397.json +25 -0
- package/public/r/comp-398.json +26 -0
- package/public/r/comp-399.json +26 -0
- package/public/r/comp-40.json +23 -0
- package/public/r/comp-400.json +20 -0
- package/public/r/comp-401.json +19 -0
- package/public/r/comp-402.json +19 -0
- package/public/r/comp-403.json +19 -0
- package/public/r/comp-404.json +19 -0
- package/public/r/comp-405.json +19 -0
- package/public/r/comp-406.json +19 -0
- package/public/r/comp-407.json +19 -0
- package/public/r/comp-408.json +19 -0
- package/public/r/comp-409.json +22 -0
- package/public/r/comp-41.json +29 -0
- package/public/r/comp-410.json +22 -0
- package/public/r/comp-411.json +22 -0
- package/public/r/comp-412.json +19 -0
- package/public/r/comp-413.json +22 -0
- package/public/r/comp-414.json +22 -0
- package/public/r/comp-415.json +22 -0
- package/public/r/comp-416.json +23 -0
- package/public/r/comp-417.json +22 -0
- package/public/r/comp-418.json +23 -0
- package/public/r/comp-419.json +23 -0
- package/public/r/comp-42.json +30 -0
- package/public/r/comp-420.json +23 -0
- package/public/r/comp-421.json +23 -0
- package/public/r/comp-422.json +23 -0
- package/public/r/comp-423.json +24 -0
- package/public/r/comp-424.json +22 -0
- package/public/r/comp-425.json +23 -0
- package/public/r/comp-426.json +23 -0
- package/public/r/comp-427.json +23 -0
- package/public/r/comp-428.json +23 -0
- package/public/r/comp-429.json +23 -0
- package/public/r/comp-43.json +32 -0
- package/public/r/comp-430.json +23 -0
- package/public/r/comp-431.json +23 -0
- package/public/r/comp-432.json +23 -0
- package/public/r/comp-433.json +24 -0
- package/public/r/comp-434.json +24 -0
- package/public/r/comp-435.json +24 -0
- package/public/r/comp-436.json +24 -0
- package/public/r/comp-437.json +25 -0
- package/public/r/comp-438.json +23 -0
- package/public/r/comp-439.json +24 -0
- package/public/r/comp-44.json +25 -0
- package/public/r/comp-440.json +25 -0
- package/public/r/comp-441.json +25 -0
- package/public/r/comp-442.json +24 -0
- package/public/r/comp-443.json +24 -0
- package/public/r/comp-444.json +24 -0
- package/public/r/comp-445.json +24 -0
- package/public/r/comp-446.json +24 -0
- package/public/r/comp-447.json +25 -0
- package/public/r/comp-448.json +23 -0
- package/public/r/comp-449.json +23 -0
- package/public/r/comp-45.json +25 -0
- package/public/r/comp-450.json +23 -0
- package/public/r/comp-451.json +23 -0
- package/public/r/comp-452.json +23 -0
- package/public/r/comp-453.json +25 -0
- package/public/r/comp-454.json +22 -0
- package/public/r/comp-455.json +22 -0
- package/public/r/comp-456.json +22 -0
- package/public/r/comp-457.json +21 -0
- package/public/r/comp-458.json +22 -0
- package/public/r/comp-459.json +26 -0
- package/public/r/comp-46.json +26 -0
- package/public/r/comp-460.json +26 -0
- package/public/r/comp-461.json +27 -0
- package/public/r/comp-462.json +27 -0
- package/public/r/comp-463.json +30 -0
- package/public/r/comp-464.json +29 -0
- package/public/r/comp-465.json +29 -0
- package/public/r/comp-466.json +21 -0
- package/public/r/comp-467.json +23 -0
- package/public/r/comp-468.json +21 -0
- package/public/r/comp-469.json +21 -0
- package/public/r/comp-47.json +32 -0
- package/public/r/comp-470.json +21 -0
- package/public/r/comp-471.json +21 -0
- package/public/r/comp-472.json +23 -0
- package/public/r/comp-473.json +24 -0
- package/public/r/comp-474.json +22 -0
- package/public/r/comp-475.json +22 -0
- package/public/r/comp-476.json +21 -0
- package/public/r/comp-477.json +31 -0
- package/public/r/comp-478.json +36 -0
- package/public/r/comp-479.json +28 -0
- package/public/r/comp-48.json +32 -0
- package/public/r/comp-480.json +30 -0
- package/public/r/comp-481.json +34 -0
- package/public/r/comp-482.json +33 -0
- package/public/r/comp-483.json +37 -0
- package/public/r/comp-484.json +41 -0
- package/public/r/comp-485.json +43 -0
- package/public/r/comp-486.json +23 -0
- package/public/r/comp-487.json +26 -0
- package/public/r/comp-488.json +27 -0
- package/public/r/comp-489.json +28 -0
- package/public/r/comp-49.json +32 -0
- package/public/r/comp-490.json +23 -0
- package/public/r/comp-491.json +24 -0
- package/public/r/comp-492.json +25 -0
- package/public/r/comp-493.json +23 -0
- package/public/r/comp-494.json +23 -0
- package/public/r/comp-495.json +24 -0
- package/public/r/comp-496.json +23 -0
- package/public/r/comp-497.json +24 -0
- package/public/r/comp-498.json +24 -0
- package/public/r/comp-499.json +24 -0
- package/public/r/comp-50.json +32 -0
- package/public/r/comp-500.json +25 -0
- package/public/r/comp-501.json +25 -0
- package/public/r/comp-502.json +26 -0
- package/public/r/comp-503.json +27 -0
- package/public/r/comp-504.json +28 -0
- package/public/r/comp-505.json +28 -0
- package/public/r/comp-506.json +26 -0
- package/public/r/comp-507.json +27 -0
- package/public/r/comp-508.json +25 -0
- package/public/r/comp-509.json +25 -0
- package/public/r/comp-51.json +23 -0
- package/public/r/comp-510.json +24 -0
- package/public/r/comp-511.json +27 -0
- package/public/r/comp-512.json +27 -0
- package/public/r/comp-513.json +21 -0
- package/public/r/comp-514.json +21 -0
- package/public/r/comp-515.json +21 -0
- package/public/r/comp-516.json +22 -0
- package/public/r/comp-517.json +22 -0
- package/public/r/comp-518.json +21 -0
- package/public/r/comp-519.json +21 -0
- package/public/r/comp-52.json +23 -0
- package/public/r/comp-520.json +22 -0
- package/public/r/comp-521.json +22 -0
- package/public/r/comp-522.json +21 -0
- package/public/r/comp-523.json +21 -0
- package/public/r/comp-524.json +21 -0
- package/public/r/comp-525.json +21 -0
- package/public/r/comp-526.json +22 -0
- package/public/r/comp-527.json +23 -0
- package/public/r/comp-528.json +22 -0
- package/public/r/comp-529.json +22 -0
- package/public/r/comp-53.json +24 -0
- package/public/r/comp-530.json +22 -0
- package/public/r/comp-531.json +22 -0
- package/public/r/comp-532.json +22 -0
- package/public/r/comp-533.json +22 -0
- package/public/r/comp-534.json +22 -0
- package/public/r/comp-535.json +22 -0
- package/public/r/comp-536.json +22 -0
- package/public/r/comp-537.json +22 -0
- package/public/r/comp-538.json +22 -0
- package/public/r/comp-539.json +22 -0
- package/public/r/comp-54.json +26 -0
- package/public/r/comp-540.json +21 -0
- package/public/r/comp-541.json +21 -0
- package/public/r/comp-542.json +124 -0
- package/public/r/comp-543.json +29 -0
- package/public/r/comp-544.json +26 -0
- package/public/r/comp-545.json +29 -0
- package/public/r/comp-546.json +29 -0
- package/public/r/comp-547.json +29 -0
- package/public/r/comp-548.json +29 -0
- package/public/r/comp-549.json +29 -0
- package/public/r/comp-55.json +27 -0
- package/public/r/comp-550.json +29 -0
- package/public/r/comp-551.json +30 -0
- package/public/r/comp-552.json +29 -0
- package/public/r/comp-553.json +29 -0
- package/public/r/comp-554.json +36 -0
- package/public/r/comp-555.json +23 -0
- package/public/r/comp-556.json +23 -0
- package/public/r/comp-557.json +23 -0
- package/public/r/comp-558.json +23 -0
- package/public/r/comp-559.json +23 -0
- package/public/r/comp-56.json +26 -0
- package/public/r/comp-560.json +23 -0
- package/public/r/comp-561.json +25 -0
- package/public/r/comp-562.json +23 -0
- package/public/r/comp-563.json +23 -0
- package/public/r/comp-564.json +24 -0
- package/public/r/comp-565.json +20 -0
- package/public/r/comp-566.json +20 -0
- package/public/r/comp-567.json +20 -0
- package/public/r/comp-568.json +20 -0
- package/public/r/comp-569.json +20 -0
- package/public/r/comp-57.json +26 -0
- package/public/r/comp-570.json +20 -0
- package/public/r/comp-571.json +23 -0
- package/public/r/comp-572.json +23 -0
- package/public/r/comp-573.json +20 -0
- package/public/r/comp-574.json +22 -0
- package/public/r/comp-575.json +20 -0
- package/public/r/comp-576.json +21 -0
- package/public/r/comp-577.json +23 -0
- package/public/r/comp-578.json +28 -0
- package/public/r/comp-579.json +28 -0
- package/public/r/comp-58.json +25 -0
- package/public/r/comp-580.json +29 -0
- package/public/r/comp-581.json +45 -0
- package/public/r/comp-582.json +42 -0
- package/public/r/comp-583.json +41 -0
- package/public/r/comp-584.json +41 -0
- package/public/r/comp-585.json +41 -0
- package/public/r/comp-586.json +34 -0
- package/public/r/comp-587.json +29 -0
- package/public/r/comp-588.json +33 -0
- package/public/r/comp-589.json +37 -0
- package/public/r/comp-59.json +22 -0
- package/public/r/comp-590.json +40 -0
- package/public/r/comp-591.json +29 -0
- package/public/r/comp-592.json +41 -0
- package/public/r/comp-593.json +36 -0
- package/public/r/comp-594.json +33 -0
- package/public/r/comp-595.json +22 -0
- package/public/r/comp-596.json +26 -0
- package/public/r/comp-597.json +22 -0
- package/public/r/comp-598.json +22 -0
- package/public/r/comp-599.json +22 -0
- package/public/r/comp-60.json +23 -0
- package/public/r/comp-600.json +17 -0
- package/public/r/comp-601.json +20 -0
- package/public/r/comp-602.json +17 -0
- package/public/r/comp-603.json +20 -0
- package/public/r/comp-604.json +17 -0
- package/public/r/comp-605.json +17 -0
- package/public/r/comp-606.json +20 -0
- package/public/r/comp-607.json +20 -0
- package/public/r/comp-608.json +20 -0
- package/public/r/comp-609.json +20 -0
- package/public/r/comp-61.json +23 -0
- package/public/r/comp-610.json +23 -0
- package/public/r/comp-611.json +23 -0
- package/public/r/comp-612.json +17 -0
- package/public/r/comp-613.json +20 -0
- package/public/r/comp-614.json +17 -0
- package/public/r/comp-615.json +26 -0
- package/public/r/comp-616.json +26 -0
- package/public/r/comp-617.json +26 -0
- package/public/r/comp-62.json +23 -0
- package/public/r/comp-63.json +22 -0
- package/public/r/comp-64.json +23 -0
- package/public/r/comp-65.json +22 -0
- package/public/r/comp-66.json +22 -0
- package/public/r/comp-67.json +23 -0
- package/public/r/comp-68.json +24 -0
- package/public/r/comp-69.json +24 -0
- package/public/r/comp-70.json +24 -0
- package/public/r/comp-71.json +22 -0
- package/public/r/comp-72.json +21 -0
- package/public/r/comp-73.json +18 -0
- package/public/r/comp-74.json +27 -0
- package/public/r/comp-75.json +22 -0
- package/public/r/comp-76.json +23 -0
- package/public/r/comp-77.json +22 -0
- package/public/r/comp-78.json +21 -0
- package/public/r/comp-79.json +22 -0
- package/public/r/comp-80.json +21 -0
- package/public/r/comp-81.json +21 -0
- package/public/r/comp-82.json +22 -0
- package/public/r/comp-83.json +21 -0
- package/public/r/comp-84.json +21 -0
- package/public/r/comp-85.json +22 -0
- package/public/r/comp-86.json +22 -0
- package/public/r/comp-87.json +21 -0
- package/public/r/comp-88.json +22 -0
- package/public/r/comp-89.json +21 -0
- package/public/r/comp-90.json +22 -0
- package/public/r/comp-91.json +22 -0
- package/public/r/comp-92.json +22 -0
- package/public/r/comp-93.json +22 -0
- package/public/r/comp-94.json +24 -0
- package/public/r/comp-95.json +26 -0
- package/public/r/comp-96.json +21 -0
- package/public/r/comp-97.json +21 -0
- package/public/r/comp-98.json +22 -0
- package/public/r/comp-99.json +23 -0
- package/public/r/comparison-event-handlers.json +1 -1
- package/public/r/comparison-hover.json +1 -1
- package/public/r/comparison.json +1 -1
- package/public/r/context-menu.json +16 -0
- package/public/r/contribution-graph-custom-footer.json +1 -1
- package/public/r/credit-card-apple.json +1 -1
- package/public/r/credit-card-back.json +1 -1
- package/public/r/credit-card.json +1 -1
- package/public/r/cropper.json +15 -0
- package/public/r/cursor-color.json +1 -1
- package/public/r/cursor-message.json +1 -1
- package/public/r/cursor-name-message.json +1 -1
- package/public/r/cursor-name.json +1 -1
- package/public/r/cursor.json +1 -1
- package/public/r/dashboard-1.json +263 -0
- package/public/r/dashboard-2.json +264 -0
- package/public/r/dashboard-3.json +225 -0
- package/public/r/datefield-rac.json +15 -0
- package/public/r/deck-controlled.json +1 -1
- package/public/r/deck-product-cards.json +1 -1
- package/public/r/deck.json +1 -1
- package/public/r/dialog-close-confirmation.json +1 -1
- package/public/r/dialog-demo.json +1 -1
- package/public/r/dialog-from-menu.json +1 -1
- package/public/r/dialog-nested.json +1 -1
- package/public/r/dialog-stack-controlled.json +1 -1
- package/public/r/dialog-stack-navigation.json +1 -1
- package/public/r/dialog-stack-six.json +1 -1
- package/public/r/dialog-stack.json +1 -1
- package/public/r/dropdown-menu.json +15 -0
- package/public/r/dropzone-custom-empty-state.json +1 -1
- package/public/r/dropzone-image-preview.json +1 -1
- package/public/r/editor.json +1 -1
- package/public/r/emails.json +243 -0
- package/public/r/empty-demo.json +1 -1
- package/public/r/example-app-calendar.json +323 -0
- package/public/r/field-2.json +19 -0
- package/public/r/field-autocomplete.json +1 -1
- package/public/r/field-checkbox-group.json +1 -1
- package/public/r/field-checkbox.json +1 -1
- package/public/r/field-combobox-multiple.json +1 -1
- package/public/r/field-combobox.json +1 -1
- package/public/r/field-complete-form.json +1 -1
- package/public/r/field-demo.json +1 -1
- package/public/r/field-disabled.json +1 -1
- package/public/r/field-error.json +1 -1
- package/public/r/field-number-field.json +1 -1
- package/public/r/field-radio.json +1 -1
- package/public/r/field-required.json +1 -1
- package/public/r/field-select.json +1 -1
- package/public/r/field-slider.json +1 -1
- package/public/r/field-switch.json +1 -1
- package/public/r/field-textarea.json +1 -1
- package/public/r/field-validity.json +1 -1
- package/public/r/fieldset-demo.json +1 -1
- package/public/r/files.json +195 -0
- package/public/r/form-demo.json +1 -1
- package/public/r/form-zod.json +1 -1
- package/public/r/form.json +1 -1
- package/public/r/frame-demo.json +1 -1
- package/public/r/gantt-lanes.json +1 -1
- package/public/r/gantt-no-sidebar.json +1 -1
- package/public/r/gantt-read-only.json +1 -1
- package/public/r/gantt.json +1 -1
- package/public/r/glimpse-custom.json +1 -1
- package/public/r/glimpse.json +1 -1
- package/public/r/gooey-toast-demo.json +1 -1
- package/public/r/gooey-toast-promise.json +1 -1
- package/public/r/gooey-toast-states.json +1 -1
- package/public/r/gooey-toast-with-button.json +1 -1
- package/public/r/group-demo.json +1 -1
- package/public/r/group-with-input.json +1 -1
- package/public/r/hero.json +1 -1
- package/public/r/hover-card.json +15 -0
- package/public/r/image-crop-circular.json +1 -1
- package/public/r/image-crop-custom.json +1 -1
- package/public/r/image-crop.json +1 -1
- package/public/r/image-zoom-background.json +1 -1
- package/public/r/image-zoom-margin.json +1 -1
- package/public/r/image-zoom.json +1 -1
- package/public/r/input-demo.json +1 -1
- package/public/r/input-disabled.json +1 -1
- package/public/r/input-file.json +1 -1
- package/public/r/input-group.json +20 -0
- package/public/r/input-lg.json +1 -1
- package/public/r/input-sm.json +1 -1
- package/public/r/input-with-button.json +1 -1
- package/public/r/input-with-label.json +1 -1
- package/public/r/kanban-simple.json +1 -1
- package/public/r/kanban.json +1 -1
- package/public/r/kbd.json +12 -0
- package/public/r/leads.json +271 -0
- package/public/r/list-simple.json +1 -1
- package/public/r/list.json +1 -1
- package/public/r/maps.json +188 -0
- package/public/r/menu-checkbox.json +1 -1
- package/public/r/menu-close-on-click.json +1 -1
- package/public/r/menu-demo.json +1 -1
- package/public/r/menu-group-labels.json +1 -1
- package/public/r/menu-hover.json +1 -1
- package/public/r/menu-link.json +1 -1
- package/public/r/menu-nested.json +1 -1
- package/public/r/menu-radio-group.json +1 -1
- package/public/r/meter-demo.json +1 -1
- package/public/r/meter-with-formatted-value.json +1 -1
- package/public/r/meter-with-range.json +1 -1
- package/public/r/mini-calendar-controlled.json +1 -1
- package/public/r/multiselect.json +15 -0
- package/public/r/navigation-menu.json +12 -0
- package/public/r/number-field-form.json +1 -1
- package/public/r/number-field-with-label.json +1 -1
- package/public/r/number-field-with-scrub.json +1 -1
- package/public/r/number-field-with-step.json +1 -1
- package/public/r/pill-avatar-group.json +1 -1
- package/public/r/pill-delta.json +1 -1
- package/public/r/pill-icon.json +1 -1
- package/public/r/pill-indicator.json +1 -1
- package/public/r/pill-status.json +1 -1
- package/public/r/pill.json +1 -1
- package/public/r/popover-demo.json +1 -1
- package/public/r/popover-with-close.json +1 -1
- package/public/r/preview-card-demo.json +1 -1
- package/public/r/pricing.json +1 -1
- package/public/r/progress-with-formatted-value.json +1 -1
- package/public/r/progress-with-label-value.json +1 -1
- package/public/r/radio-group-card.json +1 -1
- package/public/r/radio-group-demo.json +1 -1
- package/public/r/radio-group-disabled.json +1 -1
- package/public/r/radio-group-form.json +1 -1
- package/public/r/radio-group-with-description.json +1 -1
- package/public/r/reel-custom.json +1 -1
- package/public/r/reel-images.json +1 -1
- package/public/r/reel-minimal.json +1 -1
- package/public/r/reel.json +1 -1
- package/public/r/registry.json +18217 -0
- package/public/r/relative-time-controlled.json +1 -1
- package/public/r/relative-time-format-date.json +1 -1
- package/public/r/relative-time-format-time.json +1 -1
- package/public/r/relative-time.json +1 -1
- package/public/r/rentals.json +187 -0
- package/public/r/roadmap.json +1 -1
- package/public/r/sandbox-no-file-explorer.json +1 -1
- package/public/r/sandbox.json +1 -1
- package/public/r/scroll-area-both.json +1 -1
- package/public/r/scroll-area-demo.json +1 -1
- package/public/r/scroll-area-horizontal.json +1 -1
- package/public/r/select-demo.json +1 -1
- package/public/r/select-disabled.json +1 -1
- package/public/r/select-form.json +1 -1
- package/public/r/select-lg.json +1 -1
- package/public/r/select-native.json +12 -0
- package/public/r/select-sm.json +1 -1
- package/public/r/select-with-groups.json +1 -1
- package/public/r/select-without-alignment.json +1 -1
- package/public/r/select.json +1 -1
- package/public/r/separator-demo.json +1 -1
- package/public/r/sheet-demo.json +1 -1
- package/public/r/sheet-position.json +1 -1
- package/public/r/sidebar-nav.json +22 -0
- package/public/r/skeleton-demo.json +1 -1
- package/public/r/skeleton.json +1 -1
- package/public/r/slider-form.json +1 -1
- package/public/r/slider-with-label-value.json +1 -1
- package/public/r/sonner.json +16 -0
- package/public/r/stats-card.json +22 -0
- package/public/r/status-custom.json +1 -1
- package/public/r/stepper.json +15 -0
- package/public/r/stories-images.json +1 -1
- package/public/r/switch-card.json +1 -1
- package/public/r/switch-demo.json +1 -1
- package/public/r/switch-disabled.json +1 -1
- package/public/r/switch-form.json +1 -1
- package/public/r/switch-with-description.json +1 -1
- package/public/r/table-demo.json +1 -1
- package/public/r/table-framed.json +1 -1
- package/public/r/table-simple.json +1 -1
- package/public/r/table.json +1 -1
- package/public/r/tabs-demo.json +1 -1
- package/public/r/tabs-underline-vertical.json +1 -1
- package/public/r/tabs-underline.json +1 -1
- package/public/r/tabs-vertical.json +1 -1
- package/public/r/tags-create.json +1 -1
- package/public/r/tags-filter.json +1 -1
- package/public/r/tags.json +1 -1
- package/public/r/template-auth-eight.json +28 -0
- package/public/r/template-auth-five.json +28 -0
- package/public/r/template-auth-four.json +29 -0
- package/public/r/template-auth-nine.json +28 -0
- package/public/r/template-auth-one.json +49 -0
- package/public/r/template-auth-seven.json +29 -0
- package/public/r/template-auth-six.json +28 -0
- package/public/r/template-auth-three.json +49 -0
- package/public/r/template-auth-two.json +44 -0
- package/public/r/template-hero-eight.json +83 -0
- package/public/r/template-hero-eleven.json +29 -0
- package/public/r/template-hero-five.json +35 -0
- package/public/r/template-hero-four.json +35 -0
- package/public/r/template-hero-fourteen.json +101 -0
- package/public/r/template-hero-nine.json +74 -0
- package/public/r/template-hero-one.json +90 -0
- package/public/r/template-hero-seven.json +83 -0
- package/public/r/template-hero-six.json +83 -0
- package/public/r/template-hero-ten.json +77 -0
- package/public/r/template-hero-thirteen.json +101 -0
- package/public/r/template-hero-three.json +35 -0
- package/public/r/template-hero-twelve.json +30 -0
- package/public/r/template-hero-two.json +96 -0
- package/public/r/template-logo-cloud-five.json +68 -0
- package/public/r/template-logo-cloud-four.json +71 -0
- package/public/r/template-logo-cloud-one.json +89 -0
- package/public/r/template-logo-cloud-three.json +71 -0
- package/public/r/template-logo-cloud-two.json +74 -0
- package/public/r/textarea-demo.json +1 -1
- package/public/r/textarea-disabled.json +1 -1
- package/public/r/textarea-form.json +1 -1
- package/public/r/textarea-lg.json +1 -1
- package/public/r/textarea-sm.json +1 -1
- package/public/r/textarea-with-label.json +1 -1
- package/public/r/ticker-inline.json +1 -1
- package/public/r/timeline.json +15 -0
- package/public/r/toast-demo.json +1 -1
- package/public/r/toast-heights.json +1 -1
- package/public/r/toast-loading.json +1 -1
- package/public/r/toast-promise.json +1 -1
- package/public/r/toast-with-action.json +1 -1
- package/public/r/toast-with-status.json +1 -1
- package/public/r/toggle-demo.json +1 -1
- package/public/r/toggle-disabled.json +1 -1
- package/public/r/toggle-group-demo.json +1 -1
- package/public/r/toggle-group-disabled.json +1 -1
- package/public/r/toggle-group-lg.json +1 -1
- package/public/r/toggle-group-multiple.json +1 -1
- package/public/r/toggle-group-outline-with-separator.json +1 -1
- package/public/r/toggle-group-outline.json +1 -1
- package/public/r/toggle-group-sm.json +1 -1
- package/public/r/toggle-group-with-disabled-item.json +1 -1
- package/public/r/toggle-icon-group.json +1 -1
- package/public/r/toggle-lg.json +1 -1
- package/public/r/toggle-outline.json +1 -1
- package/public/r/toggle-sm.json +1 -1
- package/public/r/toggle-with-icon.json +1 -1
- package/public/r/toolbar-demo.json +1 -1
- package/public/r/tooltip-demo.json +1 -1
- package/public/r/tooltip-grouped.json +1 -1
- package/public/r/tree-controlled.json +1 -1
- package/public/r/tree-custom-icons.json +1 -1
- package/public/r/tree-no-lines.json +1 -1
- package/public/r/tree-simple.json +1 -1
- package/public/r/tree.json +1 -1
- package/public/r/typography.json +1 -1
- package/public/r/ui.json +5 -0
- package/public/r/use-character-limit.json +12 -0
- package/public/r/use-file-upload.json +12 -0
- package/public/r/use-pagination.json +12 -0
- package/public/r/use-slider-with-input.json +12 -0
- package/public/r/use-toast.json +12 -0
- package/public/r/utils.json +16 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "comp-546",
|
|
4
|
+
"type": "registry:component",
|
|
5
|
+
"registryDependencies": [
|
|
6
|
+
"https://loveui.dev/building-blocks/r/button.json"
|
|
7
|
+
],
|
|
8
|
+
"files": [
|
|
9
|
+
{
|
|
10
|
+
"path": "registry/default/components/comp-546.tsx",
|
|
11
|
+
"content": "\"use client\"\n\nimport { AlertCircleIcon, ImageIcon, UploadIcon, XIcon } from \"lucide-react\"\n\nimport { useFileUpload } from \"@/registry/building-blocks/default/hooks/use-file-upload\"\nimport { Button } from \"@/registry/building-blocks/default/ui/button\"\n\n// Create some dummy initial files\nconst initialFiles = [\n {\n name: \"image-01.jpg\",\n size: 1528737,\n type: \"image/jpeg\",\n url: \"https://picsum.photos/1000/800?grayscale&random=1\",\n id: \"image-01-123456789\",\n },\n {\n name: \"image-02.jpg\",\n size: 1528737,\n type: \"image/jpeg\",\n url: \"https://picsum.photos/1000/800?grayscale&random=2\",\n id: \"image-02-123456789\",\n },\n {\n name: \"image-03.jpg\",\n size: 1528737,\n type: \"image/jpeg\",\n url: \"https://picsum.photos/1000/800?grayscale&random=3\",\n id: \"image-03-123456789\",\n },\n {\n name: \"image-04.jpg\",\n size: 1528737,\n type: \"image/jpeg\",\n url: \"https://picsum.photos/1000/800?grayscale&random=4\",\n id: \"image-04-123456789\",\n },\n]\n\nexport default function Component() {\n const maxSizeMB = 5\n const maxSize = maxSizeMB * 1024 * 1024 // 5MB default\n const maxFiles = 6\n\n const [\n { files, isDragging, errors },\n {\n handleDragEnter,\n handleDragLeave,\n handleDragOver,\n handleDrop,\n openFileDialog,\n removeFile,\n getInputProps,\n },\n ] = useFileUpload({\n accept: \"image/svg+xml,image/png,image/jpeg,image/jpg,image/gif\",\n maxSize,\n multiple: true,\n maxFiles,\n initialFiles,\n })\n\n return (\n <div className=\"flex flex-col gap-2\">\n {/* Drop area */}\n <div\n onDragEnter={handleDragEnter}\n onDragLeave={handleDragLeave}\n onDragOver={handleDragOver}\n onDrop={handleDrop}\n data-dragging={isDragging || undefined}\n data-files={files.length > 0 || undefined}\n className=\"relative flex min-h-52 flex-col items-center overflow-hidden rounded-xl border border-dashed border-input p-4 transition-colors not-data-[files]:justify-center has-[input:focus]:border-ring has-[input:focus]:ring-[3px] has-[input:focus]:ring-ring/50 data-[dragging=true]:bg-accent/50\"\n >\n <input\n {...getInputProps()}\n className=\"sr-only\"\n aria-label=\"Upload image file\"\n />\n {files.length > 0 ? (\n <div className=\"flex w-full flex-col gap-3\">\n <div className=\"flex items-center justify-between gap-2\">\n <h3 className=\"truncate text-sm font-medium\">\n Uploaded Files ({files.length})\n </h3>\n <Button\n variant=\"outline\"\n size=\"sm\"\n onClick={openFileDialog}\n disabled={files.length >= maxFiles}\n >\n <UploadIcon\n className=\"-ms-0.5 size-3.5 opacity-60\"\n aria-hidden=\"true\"\n />\n Add more\n </Button>\n </div>\n\n <div className=\"grid grid-cols-2 gap-4 md:grid-cols-3\">\n {files.map((file) => (\n <div\n key={file.id}\n className=\"relative aspect-square rounded-md bg-accent\"\n >\n <img\n src={file.preview}\n alt={file.file.name}\n className=\"size-full rounded-[inherit] object-cover\"\n />\n <Button\n onClick={() => removeFile(file.id)}\n size=\"icon\"\n className=\"absolute -top-2 -right-2 size-6 rounded-full border-2 border-background shadow-none focus-visible:border-background\"\n aria-label=\"Remove image\"\n >\n <XIcon className=\"size-3.5\" />\n </Button>\n </div>\n ))}\n </div>\n </div>\n ) : (\n <div className=\"flex flex-col items-center justify-center px-4 py-3 text-center\">\n <div\n className=\"mb-2 flex size-11 shrink-0 items-center justify-center rounded-full border bg-background\"\n aria-hidden=\"true\"\n >\n <ImageIcon className=\"size-4 opacity-60\" />\n </div>\n <p className=\"mb-1.5 text-sm font-medium\">Drop your images here</p>\n <p className=\"text-xs text-muted-foreground\">\n SVG, PNG, JPG or GIF (max. {maxSizeMB}MB)\n </p>\n <Button variant=\"outline\" className=\"mt-4\" onClick={openFileDialog}>\n <UploadIcon className=\"-ms-1 opacity-60\" aria-hidden=\"true\" />\n Select images\n </Button>\n </div>\n )}\n </div>\n\n {errors.length > 0 && (\n <div\n className=\"flex items-center gap-1 text-xs text-destructive\"\n role=\"alert\"\n >\n <AlertCircleIcon className=\"size-3 shrink-0\" />\n <span>{errors[0]}</span>\n </div>\n )}\n\n <p\n aria-live=\"polite\"\n role=\"region\"\n className=\"mt-2 text-center text-xs text-muted-foreground\"\n >\n Multiple image uploader w/ image grid ∙{\" \"}\n <a\n href=\"https://github.com/loveui/blob/main/apps/origin/docs/use-file-upload.md\"\n className=\"underline hover:text-foreground\"\n >\n API\n </a>\n </p>\n </div>\n )\n}\n",
|
|
12
|
+
"type": "registry:component"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"path": "registry/default/hooks/use-file-upload.ts",
|
|
16
|
+
"content": "\"use client\"\n\nimport type React from \"react\"\nimport {\n useCallback,\n useRef,\n useState,\n type ChangeEvent,\n type DragEvent,\n type InputHTMLAttributes,\n} from \"react\"\n\nexport type FileMetadata = {\n name: string\n size: number\n type: string\n url: string\n id: string\n}\n\nexport type FileWithPreview = {\n file: File | FileMetadata\n id: string\n preview?: string\n}\n\nexport type FileUploadOptions = {\n maxFiles?: number // Only used when multiple is true, defaults to Infinity\n maxSize?: number // in bytes\n accept?: string\n multiple?: boolean // Defaults to false\n initialFiles?: FileMetadata[]\n onFilesChange?: (files: FileWithPreview[]) => void // Callback when files change\n onFilesAdded?: (addedFiles: FileWithPreview[]) => void // Callback when new files are added\n}\n\nexport type FileUploadState = {\n files: FileWithPreview[]\n isDragging: boolean\n errors: string[]\n}\n\nexport type FileUploadActions = {\n addFiles: (files: FileList | File[]) => void\n removeFile: (id: string) => void\n clearFiles: () => void\n clearErrors: () => void\n handleDragEnter: (e: DragEvent<HTMLElement>) => void\n handleDragLeave: (e: DragEvent<HTMLElement>) => void\n handleDragOver: (e: DragEvent<HTMLElement>) => void\n handleDrop: (e: DragEvent<HTMLElement>) => void\n handleFileChange: (e: ChangeEvent<HTMLInputElement>) => void\n openFileDialog: () => void\n getInputProps: (\n props?: InputHTMLAttributes<HTMLInputElement>\n ) => InputHTMLAttributes<HTMLInputElement> & {\n // Use `any` here to avoid cross-React ref type conflicts across packages\n ref: any\n }\n}\n\nexport const useFileUpload = (\n options: FileUploadOptions = {}\n): [FileUploadState, FileUploadActions] => {\n const {\n maxFiles = Infinity,\n maxSize = Infinity,\n accept = \"*\",\n multiple = false,\n initialFiles = [],\n onFilesChange,\n onFilesAdded,\n } = options\n\n const [state, setState] = useState<FileUploadState>({\n files: initialFiles.map((file) => ({\n file,\n id: file.id,\n preview: file.url,\n })),\n isDragging: false,\n errors: [],\n })\n\n const inputRef = useRef<HTMLInputElement>(null)\n\n const validateFile = useCallback(\n (file: File | FileMetadata): string | null => {\n if (file instanceof File) {\n if (file.size > maxSize) {\n return `File \"${file.name}\" exceeds the maximum size of ${formatBytes(maxSize)}.`\n }\n } else {\n if (file.size > maxSize) {\n return `File \"${file.name}\" exceeds the maximum size of ${formatBytes(maxSize)}.`\n }\n }\n\n if (accept !== \"*\") {\n const acceptedTypes = accept.split(\",\").map((type) => type.trim())\n const fileType = file instanceof File ? file.type || \"\" : file.type\n const fileExtension = `.${file instanceof File ? file.name.split(\".\").pop() : file.name.split(\".\").pop()}`\n\n const isAccepted = acceptedTypes.some((type) => {\n if (type.startsWith(\".\")) {\n return fileExtension.toLowerCase() === type.toLowerCase()\n }\n if (type.endsWith(\"/*\")) {\n const baseType = type.split(\"/\")[0]\n return fileType.startsWith(`${baseType}/`)\n }\n return fileType === type\n })\n\n if (!isAccepted) {\n return `File \"${file instanceof File ? file.name : file.name}\" is not an accepted file type.`\n }\n }\n\n return null\n },\n [accept, maxSize]\n )\n\n const createPreview = useCallback(\n (file: File | FileMetadata): string | undefined => {\n if (file instanceof File) {\n return URL.createObjectURL(file)\n }\n return file.url\n },\n []\n )\n\n const generateUniqueId = useCallback((file: File | FileMetadata): string => {\n if (file instanceof File) {\n return `${file.name}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`\n }\n return file.id\n }, [])\n\n const clearFiles = useCallback(() => {\n setState((prev) => {\n // Clean up object URLs\n prev.files.forEach((file) => {\n if (\n file.preview &&\n file.file instanceof File &&\n file.file.type.startsWith(\"image/\")\n ) {\n URL.revokeObjectURL(file.preview)\n }\n })\n\n if (inputRef.current) {\n inputRef.current.value = \"\"\n }\n\n const newState = {\n ...prev,\n files: [],\n errors: [],\n }\n\n onFilesChange?.(newState.files)\n return newState\n })\n }, [onFilesChange])\n\n const addFiles = useCallback(\n (newFiles: FileList | File[]) => {\n if (!newFiles || newFiles.length === 0) return\n\n const newFilesArray = Array.from(newFiles)\n const errors: string[] = []\n\n // Clear existing errors when new files are uploaded\n setState((prev) => ({ ...prev, errors: [] }))\n\n // In single file mode, clear existing files first\n if (!multiple) {\n clearFiles()\n }\n\n // Check if adding these files would exceed maxFiles (only in multiple mode)\n if (\n multiple &&\n maxFiles !== Infinity &&\n state.files.length + newFilesArray.length > maxFiles\n ) {\n errors.push(`You can only upload a maximum of ${maxFiles} files.`)\n setState((prev) => ({ ...prev, errors }))\n return\n }\n\n const validFiles: FileWithPreview[] = []\n\n newFilesArray.forEach((file) => {\n // Only check for duplicates if multiple files are allowed\n if (multiple) {\n const isDuplicate = state.files.some(\n (existingFile) =>\n existingFile.file.name === file.name &&\n existingFile.file.size === file.size\n )\n\n // Skip duplicate files silently\n if (isDuplicate) {\n return\n }\n }\n\n // Check file size\n if (file.size > maxSize) {\n errors.push(\n multiple\n ? `Some files exceed the maximum size of ${formatBytes(maxSize)}.`\n : `File exceeds the maximum size of ${formatBytes(maxSize)}.`\n )\n return\n }\n\n const error = validateFile(file)\n if (error) {\n errors.push(error)\n } else {\n validFiles.push({\n file,\n id: generateUniqueId(file),\n preview: createPreview(file),\n })\n }\n })\n\n // Only update state if we have valid files to add\n if (validFiles.length > 0) {\n // Call the onFilesAdded callback with the newly added valid files\n onFilesAdded?.(validFiles)\n\n setState((prev) => {\n const newFiles = !multiple\n ? validFiles\n : [...prev.files, ...validFiles]\n onFilesChange?.(newFiles)\n return {\n ...prev,\n files: newFiles,\n errors,\n }\n })\n } else if (errors.length > 0) {\n setState((prev) => ({\n ...prev,\n errors,\n }))\n }\n\n // Reset input value after handling files\n if (inputRef.current) {\n inputRef.current.value = \"\"\n }\n },\n [\n state.files,\n maxFiles,\n multiple,\n maxSize,\n validateFile,\n createPreview,\n generateUniqueId,\n clearFiles,\n onFilesChange,\n onFilesAdded,\n ]\n )\n\n const removeFile = useCallback(\n (id: string) => {\n setState((prev) => {\n const fileToRemove = prev.files.find((file) => file.id === id)\n if (\n fileToRemove &&\n fileToRemove.preview &&\n fileToRemove.file instanceof File &&\n fileToRemove.file.type.startsWith(\"image/\")\n ) {\n URL.revokeObjectURL(fileToRemove.preview)\n }\n\n const newFiles = prev.files.filter((file) => file.id !== id)\n onFilesChange?.(newFiles)\n\n return {\n ...prev,\n files: newFiles,\n errors: [],\n }\n })\n },\n [onFilesChange]\n )\n\n const clearErrors = useCallback(() => {\n setState((prev) => ({\n ...prev,\n errors: [],\n }))\n }, [])\n\n const handleDragEnter = useCallback((e: DragEvent<HTMLElement>) => {\n e.preventDefault()\n e.stopPropagation()\n setState((prev) => ({ ...prev, isDragging: true }))\n }, [])\n\n const handleDragLeave = useCallback((e: DragEvent<HTMLElement>) => {\n e.preventDefault()\n e.stopPropagation()\n\n if (e.currentTarget.contains(e.relatedTarget as Node)) {\n return\n }\n\n setState((prev) => ({ ...prev, isDragging: false }))\n }, [])\n\n const handleDragOver = useCallback((e: DragEvent<HTMLElement>) => {\n e.preventDefault()\n e.stopPropagation()\n }, [])\n\n const handleDrop = useCallback(\n (e: DragEvent<HTMLElement>) => {\n e.preventDefault()\n e.stopPropagation()\n setState((prev) => ({ ...prev, isDragging: false }))\n\n // Don't process files if the input is disabled\n if (inputRef.current?.disabled) {\n return\n }\n\n if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {\n // In single file mode, only use the first file\n if (!multiple) {\n const file = e.dataTransfer.files[0]\n addFiles([file])\n } else {\n addFiles(e.dataTransfer.files)\n }\n }\n },\n [addFiles, multiple]\n )\n\n const handleFileChange = useCallback(\n (e: ChangeEvent<HTMLInputElement>) => {\n if (e.target.files && e.target.files.length > 0) {\n addFiles(e.target.files)\n }\n },\n [addFiles]\n )\n\n const openFileDialog = useCallback(() => {\n if (inputRef.current) {\n inputRef.current.click()\n }\n }, [])\n\n const getInputProps = useCallback(\n (props: InputHTMLAttributes<HTMLInputElement> = {}) => {\n return {\n ...props,\n type: \"file\" as const,\n onChange: handleFileChange,\n accept: props.accept || accept,\n multiple: props.multiple !== undefined ? props.multiple : multiple,\n // Cast to `any` to prevent mismatched React ref type errors across workspaces\n ref: inputRef as any,\n }\n },\n [accept, multiple, handleFileChange]\n )\n\n return [\n state,\n {\n addFiles,\n removeFile,\n clearFiles,\n clearErrors,\n handleDragEnter,\n handleDragLeave,\n handleDragOver,\n handleDrop,\n handleFileChange,\n openFileDialog,\n getInputProps,\n },\n ]\n}\n\n// Helper function to format bytes to human-readable format\nexport const formatBytes = (bytes: number, decimals = 2): string => {\n if (bytes === 0) return \"0 Bytes\"\n\n const k = 1024\n const dm = decimals < 0 ? 0 : decimals\n const sizes = [\"Bytes\", \"KB\", \"MB\", \"GB\", \"TB\", \"PB\", \"EB\", \"ZB\", \"YB\"]\n\n const i = Math.floor(Math.log(bytes) / Math.log(k))\n\n return Number.parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + sizes[i]\n}\n",
|
|
17
|
+
"type": "registry:hook"
|
|
18
|
+
}
|
|
19
|
+
],
|
|
20
|
+
"meta": {
|
|
21
|
+
"tags": [
|
|
22
|
+
"upload",
|
|
23
|
+
"file",
|
|
24
|
+
"image",
|
|
25
|
+
"drag and drop"
|
|
26
|
+
],
|
|
27
|
+
"colSpan": 2
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "comp-547",
|
|
4
|
+
"type": "registry:component",
|
|
5
|
+
"registryDependencies": [
|
|
6
|
+
"https://loveui.dev/building-blocks/r/button.json"
|
|
7
|
+
],
|
|
8
|
+
"files": [
|
|
9
|
+
{
|
|
10
|
+
"path": "registry/default/components/comp-547.tsx",
|
|
11
|
+
"content": "\"use client\"\n\nimport { AlertCircleIcon, ImageIcon, UploadIcon, XIcon } from \"lucide-react\"\n\nimport {\n formatBytes,\n useFileUpload,\n} from \"@/registry/building-blocks/default/hooks/use-file-upload\"\nimport { Button } from \"@/registry/building-blocks/default/ui/button\"\n\n// Create some dummy initial files\nconst initialFiles = [\n {\n name: \"image-01.jpg\",\n size: 1528737,\n type: \"image/jpeg\",\n url: \"https://picsum.photos/1000/800?grayscale&random=1\",\n id: \"image-01-123456789\",\n },\n {\n name: \"image-02.jpg\",\n size: 2345678,\n type: \"image/jpeg\",\n url: \"https://picsum.photos/1000/800?grayscale&random=2\",\n id: \"image-02-123456789\",\n },\n {\n name: \"image-03.jpg\",\n size: 3456789,\n type: \"image/jpeg\",\n url: \"https://picsum.photos/1000/800?grayscale&random=3\",\n id: \"image-03-123456789\",\n },\n]\n\nexport default function Component() {\n const maxSizeMB = 5\n const maxSize = maxSizeMB * 1024 * 1024 // 5MB default\n const maxFiles = 6\n\n const [\n { files, isDragging, errors },\n {\n handleDragEnter,\n handleDragLeave,\n handleDragOver,\n handleDrop,\n openFileDialog,\n removeFile,\n clearFiles,\n getInputProps,\n },\n ] = useFileUpload({\n accept: \"image/svg+xml,image/png,image/jpeg,image/jpg,image/gif\",\n maxSize,\n multiple: true,\n maxFiles,\n initialFiles,\n })\n\n return (\n <div className=\"flex flex-col gap-2\">\n {/* Drop area */}\n <div\n onDragEnter={handleDragEnter}\n onDragLeave={handleDragLeave}\n onDragOver={handleDragOver}\n onDrop={handleDrop}\n data-dragging={isDragging || undefined}\n data-files={files.length > 0 || undefined}\n className=\"relative flex min-h-52 flex-col items-center overflow-hidden rounded-xl border border-dashed border-input p-4 transition-colors not-data-[files]:justify-center has-[input:focus]:border-ring has-[input:focus]:ring-[3px] has-[input:focus]:ring-ring/50 data-[dragging=true]:bg-accent/50\"\n >\n <input\n {...getInputProps()}\n className=\"sr-only\"\n aria-label=\"Upload image file\"\n />\n <div className=\"flex flex-col items-center justify-center px-4 py-3 text-center\">\n <div\n className=\"mb-2 flex size-11 shrink-0 items-center justify-center rounded-full border bg-background\"\n aria-hidden=\"true\"\n >\n <ImageIcon className=\"size-4 opacity-60\" />\n </div>\n <p className=\"mb-1.5 text-sm font-medium\">Drop your images here</p>\n <p className=\"text-xs text-muted-foreground\">\n SVG, PNG, JPG or GIF (max. {maxSizeMB}MB)\n </p>\n <Button variant=\"outline\" className=\"mt-4\" onClick={openFileDialog}>\n <UploadIcon className=\"-ms-1 opacity-60\" aria-hidden=\"true\" />\n Select images\n </Button>\n </div>\n </div>\n\n {errors.length > 0 && (\n <div\n className=\"flex items-center gap-1 text-xs text-destructive\"\n role=\"alert\"\n >\n <AlertCircleIcon className=\"size-3 shrink-0\" />\n <span>{errors[0]}</span>\n </div>\n )}\n\n {/* File list */}\n {files.length > 0 && (\n <div className=\"space-y-2\">\n {files.map((file) => (\n <div\n key={file.id}\n className=\"flex items-center justify-between gap-2 rounded-lg border bg-background p-2 pe-3\"\n >\n <div className=\"flex items-center gap-3 overflow-hidden\">\n <div className=\"aspect-square shrink-0 rounded bg-accent\">\n <img\n src={file.preview}\n alt={file.file.name}\n className=\"size-10 rounded-[inherit] object-cover\"\n />\n </div>\n <div className=\"flex min-w-0 flex-col gap-0.5\">\n <p className=\"truncate text-[13px] font-medium\">\n {file.file.name}\n </p>\n <p className=\"text-xs text-muted-foreground\">\n {formatBytes(file.file.size)}\n </p>\n </div>\n </div>\n\n <Button\n size=\"icon\"\n variant=\"ghost\"\n className=\"-me-2 size-8 text-muted-foreground/80 hover:bg-transparent hover:text-foreground\"\n onClick={() => removeFile(file.id)}\n aria-label=\"Remove file\"\n >\n <XIcon aria-hidden=\"true\" />\n </Button>\n </div>\n ))}\n\n {/* Remove all files button */}\n {files.length > 1 && (\n <div>\n <Button size=\"sm\" variant=\"outline\" onClick={clearFiles}>\n Remove all files\n </Button>\n </div>\n )}\n </div>\n )}\n\n <p\n aria-live=\"polite\"\n role=\"region\"\n className=\"mt-2 text-center text-xs text-muted-foreground\"\n >\n Multiple image uploader w/ image list ∙{\" \"}\n <a\n href=\"https://github.com/loveui/blob/main/apps/origin/docs/use-file-upload.md\"\n className=\"underline hover:text-foreground\"\n >\n API\n </a>\n </p>\n </div>\n )\n}\n",
|
|
12
|
+
"type": "registry:component"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"path": "registry/default/hooks/use-file-upload.ts",
|
|
16
|
+
"content": "\"use client\"\n\nimport type React from \"react\"\nimport {\n useCallback,\n useRef,\n useState,\n type ChangeEvent,\n type DragEvent,\n type InputHTMLAttributes,\n} from \"react\"\n\nexport type FileMetadata = {\n name: string\n size: number\n type: string\n url: string\n id: string\n}\n\nexport type FileWithPreview = {\n file: File | FileMetadata\n id: string\n preview?: string\n}\n\nexport type FileUploadOptions = {\n maxFiles?: number // Only used when multiple is true, defaults to Infinity\n maxSize?: number // in bytes\n accept?: string\n multiple?: boolean // Defaults to false\n initialFiles?: FileMetadata[]\n onFilesChange?: (files: FileWithPreview[]) => void // Callback when files change\n onFilesAdded?: (addedFiles: FileWithPreview[]) => void // Callback when new files are added\n}\n\nexport type FileUploadState = {\n files: FileWithPreview[]\n isDragging: boolean\n errors: string[]\n}\n\nexport type FileUploadActions = {\n addFiles: (files: FileList | File[]) => void\n removeFile: (id: string) => void\n clearFiles: () => void\n clearErrors: () => void\n handleDragEnter: (e: DragEvent<HTMLElement>) => void\n handleDragLeave: (e: DragEvent<HTMLElement>) => void\n handleDragOver: (e: DragEvent<HTMLElement>) => void\n handleDrop: (e: DragEvent<HTMLElement>) => void\n handleFileChange: (e: ChangeEvent<HTMLInputElement>) => void\n openFileDialog: () => void\n getInputProps: (\n props?: InputHTMLAttributes<HTMLInputElement>\n ) => InputHTMLAttributes<HTMLInputElement> & {\n // Use `any` here to avoid cross-React ref type conflicts across packages\n ref: any\n }\n}\n\nexport const useFileUpload = (\n options: FileUploadOptions = {}\n): [FileUploadState, FileUploadActions] => {\n const {\n maxFiles = Infinity,\n maxSize = Infinity,\n accept = \"*\",\n multiple = false,\n initialFiles = [],\n onFilesChange,\n onFilesAdded,\n } = options\n\n const [state, setState] = useState<FileUploadState>({\n files: initialFiles.map((file) => ({\n file,\n id: file.id,\n preview: file.url,\n })),\n isDragging: false,\n errors: [],\n })\n\n const inputRef = useRef<HTMLInputElement>(null)\n\n const validateFile = useCallback(\n (file: File | FileMetadata): string | null => {\n if (file instanceof File) {\n if (file.size > maxSize) {\n return `File \"${file.name}\" exceeds the maximum size of ${formatBytes(maxSize)}.`\n }\n } else {\n if (file.size > maxSize) {\n return `File \"${file.name}\" exceeds the maximum size of ${formatBytes(maxSize)}.`\n }\n }\n\n if (accept !== \"*\") {\n const acceptedTypes = accept.split(\",\").map((type) => type.trim())\n const fileType = file instanceof File ? file.type || \"\" : file.type\n const fileExtension = `.${file instanceof File ? file.name.split(\".\").pop() : file.name.split(\".\").pop()}`\n\n const isAccepted = acceptedTypes.some((type) => {\n if (type.startsWith(\".\")) {\n return fileExtension.toLowerCase() === type.toLowerCase()\n }\n if (type.endsWith(\"/*\")) {\n const baseType = type.split(\"/\")[0]\n return fileType.startsWith(`${baseType}/`)\n }\n return fileType === type\n })\n\n if (!isAccepted) {\n return `File \"${file instanceof File ? file.name : file.name}\" is not an accepted file type.`\n }\n }\n\n return null\n },\n [accept, maxSize]\n )\n\n const createPreview = useCallback(\n (file: File | FileMetadata): string | undefined => {\n if (file instanceof File) {\n return URL.createObjectURL(file)\n }\n return file.url\n },\n []\n )\n\n const generateUniqueId = useCallback((file: File | FileMetadata): string => {\n if (file instanceof File) {\n return `${file.name}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`\n }\n return file.id\n }, [])\n\n const clearFiles = useCallback(() => {\n setState((prev) => {\n // Clean up object URLs\n prev.files.forEach((file) => {\n if (\n file.preview &&\n file.file instanceof File &&\n file.file.type.startsWith(\"image/\")\n ) {\n URL.revokeObjectURL(file.preview)\n }\n })\n\n if (inputRef.current) {\n inputRef.current.value = \"\"\n }\n\n const newState = {\n ...prev,\n files: [],\n errors: [],\n }\n\n onFilesChange?.(newState.files)\n return newState\n })\n }, [onFilesChange])\n\n const addFiles = useCallback(\n (newFiles: FileList | File[]) => {\n if (!newFiles || newFiles.length === 0) return\n\n const newFilesArray = Array.from(newFiles)\n const errors: string[] = []\n\n // Clear existing errors when new files are uploaded\n setState((prev) => ({ ...prev, errors: [] }))\n\n // In single file mode, clear existing files first\n if (!multiple) {\n clearFiles()\n }\n\n // Check if adding these files would exceed maxFiles (only in multiple mode)\n if (\n multiple &&\n maxFiles !== Infinity &&\n state.files.length + newFilesArray.length > maxFiles\n ) {\n errors.push(`You can only upload a maximum of ${maxFiles} files.`)\n setState((prev) => ({ ...prev, errors }))\n return\n }\n\n const validFiles: FileWithPreview[] = []\n\n newFilesArray.forEach((file) => {\n // Only check for duplicates if multiple files are allowed\n if (multiple) {\n const isDuplicate = state.files.some(\n (existingFile) =>\n existingFile.file.name === file.name &&\n existingFile.file.size === file.size\n )\n\n // Skip duplicate files silently\n if (isDuplicate) {\n return\n }\n }\n\n // Check file size\n if (file.size > maxSize) {\n errors.push(\n multiple\n ? `Some files exceed the maximum size of ${formatBytes(maxSize)}.`\n : `File exceeds the maximum size of ${formatBytes(maxSize)}.`\n )\n return\n }\n\n const error = validateFile(file)\n if (error) {\n errors.push(error)\n } else {\n validFiles.push({\n file,\n id: generateUniqueId(file),\n preview: createPreview(file),\n })\n }\n })\n\n // Only update state if we have valid files to add\n if (validFiles.length > 0) {\n // Call the onFilesAdded callback with the newly added valid files\n onFilesAdded?.(validFiles)\n\n setState((prev) => {\n const newFiles = !multiple\n ? validFiles\n : [...prev.files, ...validFiles]\n onFilesChange?.(newFiles)\n return {\n ...prev,\n files: newFiles,\n errors,\n }\n })\n } else if (errors.length > 0) {\n setState((prev) => ({\n ...prev,\n errors,\n }))\n }\n\n // Reset input value after handling files\n if (inputRef.current) {\n inputRef.current.value = \"\"\n }\n },\n [\n state.files,\n maxFiles,\n multiple,\n maxSize,\n validateFile,\n createPreview,\n generateUniqueId,\n clearFiles,\n onFilesChange,\n onFilesAdded,\n ]\n )\n\n const removeFile = useCallback(\n (id: string) => {\n setState((prev) => {\n const fileToRemove = prev.files.find((file) => file.id === id)\n if (\n fileToRemove &&\n fileToRemove.preview &&\n fileToRemove.file instanceof File &&\n fileToRemove.file.type.startsWith(\"image/\")\n ) {\n URL.revokeObjectURL(fileToRemove.preview)\n }\n\n const newFiles = prev.files.filter((file) => file.id !== id)\n onFilesChange?.(newFiles)\n\n return {\n ...prev,\n files: newFiles,\n errors: [],\n }\n })\n },\n [onFilesChange]\n )\n\n const clearErrors = useCallback(() => {\n setState((prev) => ({\n ...prev,\n errors: [],\n }))\n }, [])\n\n const handleDragEnter = useCallback((e: DragEvent<HTMLElement>) => {\n e.preventDefault()\n e.stopPropagation()\n setState((prev) => ({ ...prev, isDragging: true }))\n }, [])\n\n const handleDragLeave = useCallback((e: DragEvent<HTMLElement>) => {\n e.preventDefault()\n e.stopPropagation()\n\n if (e.currentTarget.contains(e.relatedTarget as Node)) {\n return\n }\n\n setState((prev) => ({ ...prev, isDragging: false }))\n }, [])\n\n const handleDragOver = useCallback((e: DragEvent<HTMLElement>) => {\n e.preventDefault()\n e.stopPropagation()\n }, [])\n\n const handleDrop = useCallback(\n (e: DragEvent<HTMLElement>) => {\n e.preventDefault()\n e.stopPropagation()\n setState((prev) => ({ ...prev, isDragging: false }))\n\n // Don't process files if the input is disabled\n if (inputRef.current?.disabled) {\n return\n }\n\n if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {\n // In single file mode, only use the first file\n if (!multiple) {\n const file = e.dataTransfer.files[0]\n addFiles([file])\n } else {\n addFiles(e.dataTransfer.files)\n }\n }\n },\n [addFiles, multiple]\n )\n\n const handleFileChange = useCallback(\n (e: ChangeEvent<HTMLInputElement>) => {\n if (e.target.files && e.target.files.length > 0) {\n addFiles(e.target.files)\n }\n },\n [addFiles]\n )\n\n const openFileDialog = useCallback(() => {\n if (inputRef.current) {\n inputRef.current.click()\n }\n }, [])\n\n const getInputProps = useCallback(\n (props: InputHTMLAttributes<HTMLInputElement> = {}) => {\n return {\n ...props,\n type: \"file\" as const,\n onChange: handleFileChange,\n accept: props.accept || accept,\n multiple: props.multiple !== undefined ? props.multiple : multiple,\n // Cast to `any` to prevent mismatched React ref type errors across workspaces\n ref: inputRef as any,\n }\n },\n [accept, multiple, handleFileChange]\n )\n\n return [\n state,\n {\n addFiles,\n removeFile,\n clearFiles,\n clearErrors,\n handleDragEnter,\n handleDragLeave,\n handleDragOver,\n handleDrop,\n handleFileChange,\n openFileDialog,\n getInputProps,\n },\n ]\n}\n\n// Helper function to format bytes to human-readable format\nexport const formatBytes = (bytes: number, decimals = 2): string => {\n if (bytes === 0) return \"0 Bytes\"\n\n const k = 1024\n const dm = decimals < 0 ? 0 : decimals\n const sizes = [\"Bytes\", \"KB\", \"MB\", \"GB\", \"TB\", \"PB\", \"EB\", \"ZB\", \"YB\"]\n\n const i = Math.floor(Math.log(bytes) / Math.log(k))\n\n return Number.parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + sizes[i]\n}\n",
|
|
17
|
+
"type": "registry:hook"
|
|
18
|
+
}
|
|
19
|
+
],
|
|
20
|
+
"meta": {
|
|
21
|
+
"tags": [
|
|
22
|
+
"upload",
|
|
23
|
+
"file",
|
|
24
|
+
"image",
|
|
25
|
+
"drag and drop"
|
|
26
|
+
],
|
|
27
|
+
"colSpan": 2
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "comp-548",
|
|
4
|
+
"type": "registry:component",
|
|
5
|
+
"registryDependencies": [
|
|
6
|
+
"https://loveui.dev/building-blocks/r/button.json"
|
|
7
|
+
],
|
|
8
|
+
"files": [
|
|
9
|
+
{
|
|
10
|
+
"path": "registry/default/components/comp-548.tsx",
|
|
11
|
+
"content": "\"use client\"\n\nimport { AlertCircleIcon, PaperclipIcon, UploadIcon, XIcon } from \"lucide-react\"\n\nimport {\n formatBytes,\n useFileUpload,\n} from \"@/registry/building-blocks/default/hooks/use-file-upload\"\nimport { Button } from \"@/registry/building-blocks/default/ui/button\"\n\n// Create some dummy initial files\nconst initialFiles = [\n {\n name: \"document.pdf\",\n size: 1528737,\n type: \"application/pdf\",\n url: \"https://picsum.photos/1000/800?grayscale&random=1\",\n id: \"document.pdf-1744638436563-8u5xuls\",\n },\n]\n\nexport default function Component() {\n const maxSize = 10 * 1024 * 1024 // 10MB default\n\n const [\n { files, isDragging, errors },\n {\n handleDragEnter,\n handleDragLeave,\n handleDragOver,\n handleDrop,\n openFileDialog,\n removeFile,\n getInputProps,\n },\n ] = useFileUpload({\n maxSize,\n initialFiles,\n })\n\n const file = files[0]\n\n return (\n <div className=\"flex flex-col gap-2\">\n {/* Drop area */}\n <div\n role=\"button\"\n onClick={openFileDialog}\n onDragEnter={handleDragEnter}\n onDragLeave={handleDragLeave}\n onDragOver={handleDragOver}\n onDrop={handleDrop}\n data-dragging={isDragging || undefined}\n className=\"flex min-h-40 flex-col items-center justify-center rounded-xl border border-dashed border-input p-4 transition-colors hover:bg-accent/50 has-disabled:pointer-events-none has-disabled:opacity-50 has-[input:focus]:border-ring has-[input:focus]:ring-[3px] has-[input:focus]:ring-ring/50 data-[dragging=true]:bg-accent/50\"\n >\n <input\n {...getInputProps()}\n className=\"sr-only\"\n aria-label=\"Upload file\"\n disabled={Boolean(file)}\n />\n\n <div className=\"flex flex-col items-center justify-center text-center\">\n <div\n className=\"mb-2 flex size-11 shrink-0 items-center justify-center rounded-full border bg-background\"\n aria-hidden=\"true\"\n >\n <UploadIcon className=\"size-4 opacity-60\" />\n </div>\n <p className=\"mb-1.5 text-sm font-medium\">Upload file</p>\n <p className=\"text-xs text-muted-foreground\">\n Drag & drop or click to browse (max. {formatBytes(maxSize)})\n </p>\n </div>\n </div>\n\n {errors.length > 0 && (\n <div\n className=\"flex items-center gap-1 text-xs text-destructive\"\n role=\"alert\"\n >\n <AlertCircleIcon className=\"size-3 shrink-0\" />\n <span>{errors[0]}</span>\n </div>\n )}\n\n {/* File list */}\n {file && (\n <div className=\"space-y-2\">\n <div\n key={file.id}\n className=\"flex items-center justify-between gap-2 rounded-xl border px-4 py-2\"\n >\n <div className=\"flex items-center gap-3 overflow-hidden\">\n <PaperclipIcon\n className=\"size-4 shrink-0 opacity-60\"\n aria-hidden=\"true\"\n />\n <div className=\"min-w-0\">\n <p className=\"truncate text-[13px] font-medium\">\n {file.file.name}\n </p>\n </div>\n </div>\n\n <Button\n size=\"icon\"\n variant=\"ghost\"\n className=\"-me-2 size-8 text-muted-foreground/80 hover:bg-transparent hover:text-foreground\"\n onClick={() => removeFile(files[0]?.id)}\n aria-label=\"Remove file\"\n >\n <XIcon className=\"size-4\" aria-hidden=\"true\" />\n </Button>\n </div>\n </div>\n )}\n\n <p\n aria-live=\"polite\"\n role=\"region\"\n className=\"mt-2 text-center text-xs text-muted-foreground\"\n >\n Single file uploader w/ max size ∙{\" \"}\n <a\n href=\"https://github.com/loveui/blob/main/apps/origin/docs/use-file-upload.md\"\n className=\"underline hover:text-foreground\"\n >\n API\n </a>\n </p>\n </div>\n )\n}\n",
|
|
12
|
+
"type": "registry:component"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"path": "registry/default/hooks/use-file-upload.ts",
|
|
16
|
+
"content": "\"use client\"\n\nimport type React from \"react\"\nimport {\n useCallback,\n useRef,\n useState,\n type ChangeEvent,\n type DragEvent,\n type InputHTMLAttributes,\n} from \"react\"\n\nexport type FileMetadata = {\n name: string\n size: number\n type: string\n url: string\n id: string\n}\n\nexport type FileWithPreview = {\n file: File | FileMetadata\n id: string\n preview?: string\n}\n\nexport type FileUploadOptions = {\n maxFiles?: number // Only used when multiple is true, defaults to Infinity\n maxSize?: number // in bytes\n accept?: string\n multiple?: boolean // Defaults to false\n initialFiles?: FileMetadata[]\n onFilesChange?: (files: FileWithPreview[]) => void // Callback when files change\n onFilesAdded?: (addedFiles: FileWithPreview[]) => void // Callback when new files are added\n}\n\nexport type FileUploadState = {\n files: FileWithPreview[]\n isDragging: boolean\n errors: string[]\n}\n\nexport type FileUploadActions = {\n addFiles: (files: FileList | File[]) => void\n removeFile: (id: string) => void\n clearFiles: () => void\n clearErrors: () => void\n handleDragEnter: (e: DragEvent<HTMLElement>) => void\n handleDragLeave: (e: DragEvent<HTMLElement>) => void\n handleDragOver: (e: DragEvent<HTMLElement>) => void\n handleDrop: (e: DragEvent<HTMLElement>) => void\n handleFileChange: (e: ChangeEvent<HTMLInputElement>) => void\n openFileDialog: () => void\n getInputProps: (\n props?: InputHTMLAttributes<HTMLInputElement>\n ) => InputHTMLAttributes<HTMLInputElement> & {\n // Use `any` here to avoid cross-React ref type conflicts across packages\n ref: any\n }\n}\n\nexport const useFileUpload = (\n options: FileUploadOptions = {}\n): [FileUploadState, FileUploadActions] => {\n const {\n maxFiles = Infinity,\n maxSize = Infinity,\n accept = \"*\",\n multiple = false,\n initialFiles = [],\n onFilesChange,\n onFilesAdded,\n } = options\n\n const [state, setState] = useState<FileUploadState>({\n files: initialFiles.map((file) => ({\n file,\n id: file.id,\n preview: file.url,\n })),\n isDragging: false,\n errors: [],\n })\n\n const inputRef = useRef<HTMLInputElement>(null)\n\n const validateFile = useCallback(\n (file: File | FileMetadata): string | null => {\n if (file instanceof File) {\n if (file.size > maxSize) {\n return `File \"${file.name}\" exceeds the maximum size of ${formatBytes(maxSize)}.`\n }\n } else {\n if (file.size > maxSize) {\n return `File \"${file.name}\" exceeds the maximum size of ${formatBytes(maxSize)}.`\n }\n }\n\n if (accept !== \"*\") {\n const acceptedTypes = accept.split(\",\").map((type) => type.trim())\n const fileType = file instanceof File ? file.type || \"\" : file.type\n const fileExtension = `.${file instanceof File ? file.name.split(\".\").pop() : file.name.split(\".\").pop()}`\n\n const isAccepted = acceptedTypes.some((type) => {\n if (type.startsWith(\".\")) {\n return fileExtension.toLowerCase() === type.toLowerCase()\n }\n if (type.endsWith(\"/*\")) {\n const baseType = type.split(\"/\")[0]\n return fileType.startsWith(`${baseType}/`)\n }\n return fileType === type\n })\n\n if (!isAccepted) {\n return `File \"${file instanceof File ? file.name : file.name}\" is not an accepted file type.`\n }\n }\n\n return null\n },\n [accept, maxSize]\n )\n\n const createPreview = useCallback(\n (file: File | FileMetadata): string | undefined => {\n if (file instanceof File) {\n return URL.createObjectURL(file)\n }\n return file.url\n },\n []\n )\n\n const generateUniqueId = useCallback((file: File | FileMetadata): string => {\n if (file instanceof File) {\n return `${file.name}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`\n }\n return file.id\n }, [])\n\n const clearFiles = useCallback(() => {\n setState((prev) => {\n // Clean up object URLs\n prev.files.forEach((file) => {\n if (\n file.preview &&\n file.file instanceof File &&\n file.file.type.startsWith(\"image/\")\n ) {\n URL.revokeObjectURL(file.preview)\n }\n })\n\n if (inputRef.current) {\n inputRef.current.value = \"\"\n }\n\n const newState = {\n ...prev,\n files: [],\n errors: [],\n }\n\n onFilesChange?.(newState.files)\n return newState\n })\n }, [onFilesChange])\n\n const addFiles = useCallback(\n (newFiles: FileList | File[]) => {\n if (!newFiles || newFiles.length === 0) return\n\n const newFilesArray = Array.from(newFiles)\n const errors: string[] = []\n\n // Clear existing errors when new files are uploaded\n setState((prev) => ({ ...prev, errors: [] }))\n\n // In single file mode, clear existing files first\n if (!multiple) {\n clearFiles()\n }\n\n // Check if adding these files would exceed maxFiles (only in multiple mode)\n if (\n multiple &&\n maxFiles !== Infinity &&\n state.files.length + newFilesArray.length > maxFiles\n ) {\n errors.push(`You can only upload a maximum of ${maxFiles} files.`)\n setState((prev) => ({ ...prev, errors }))\n return\n }\n\n const validFiles: FileWithPreview[] = []\n\n newFilesArray.forEach((file) => {\n // Only check for duplicates if multiple files are allowed\n if (multiple) {\n const isDuplicate = state.files.some(\n (existingFile) =>\n existingFile.file.name === file.name &&\n existingFile.file.size === file.size\n )\n\n // Skip duplicate files silently\n if (isDuplicate) {\n return\n }\n }\n\n // Check file size\n if (file.size > maxSize) {\n errors.push(\n multiple\n ? `Some files exceed the maximum size of ${formatBytes(maxSize)}.`\n : `File exceeds the maximum size of ${formatBytes(maxSize)}.`\n )\n return\n }\n\n const error = validateFile(file)\n if (error) {\n errors.push(error)\n } else {\n validFiles.push({\n file,\n id: generateUniqueId(file),\n preview: createPreview(file),\n })\n }\n })\n\n // Only update state if we have valid files to add\n if (validFiles.length > 0) {\n // Call the onFilesAdded callback with the newly added valid files\n onFilesAdded?.(validFiles)\n\n setState((prev) => {\n const newFiles = !multiple\n ? validFiles\n : [...prev.files, ...validFiles]\n onFilesChange?.(newFiles)\n return {\n ...prev,\n files: newFiles,\n errors,\n }\n })\n } else if (errors.length > 0) {\n setState((prev) => ({\n ...prev,\n errors,\n }))\n }\n\n // Reset input value after handling files\n if (inputRef.current) {\n inputRef.current.value = \"\"\n }\n },\n [\n state.files,\n maxFiles,\n multiple,\n maxSize,\n validateFile,\n createPreview,\n generateUniqueId,\n clearFiles,\n onFilesChange,\n onFilesAdded,\n ]\n )\n\n const removeFile = useCallback(\n (id: string) => {\n setState((prev) => {\n const fileToRemove = prev.files.find((file) => file.id === id)\n if (\n fileToRemove &&\n fileToRemove.preview &&\n fileToRemove.file instanceof File &&\n fileToRemove.file.type.startsWith(\"image/\")\n ) {\n URL.revokeObjectURL(fileToRemove.preview)\n }\n\n const newFiles = prev.files.filter((file) => file.id !== id)\n onFilesChange?.(newFiles)\n\n return {\n ...prev,\n files: newFiles,\n errors: [],\n }\n })\n },\n [onFilesChange]\n )\n\n const clearErrors = useCallback(() => {\n setState((prev) => ({\n ...prev,\n errors: [],\n }))\n }, [])\n\n const handleDragEnter = useCallback((e: DragEvent<HTMLElement>) => {\n e.preventDefault()\n e.stopPropagation()\n setState((prev) => ({ ...prev, isDragging: true }))\n }, [])\n\n const handleDragLeave = useCallback((e: DragEvent<HTMLElement>) => {\n e.preventDefault()\n e.stopPropagation()\n\n if (e.currentTarget.contains(e.relatedTarget as Node)) {\n return\n }\n\n setState((prev) => ({ ...prev, isDragging: false }))\n }, [])\n\n const handleDragOver = useCallback((e: DragEvent<HTMLElement>) => {\n e.preventDefault()\n e.stopPropagation()\n }, [])\n\n const handleDrop = useCallback(\n (e: DragEvent<HTMLElement>) => {\n e.preventDefault()\n e.stopPropagation()\n setState((prev) => ({ ...prev, isDragging: false }))\n\n // Don't process files if the input is disabled\n if (inputRef.current?.disabled) {\n return\n }\n\n if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {\n // In single file mode, only use the first file\n if (!multiple) {\n const file = e.dataTransfer.files[0]\n addFiles([file])\n } else {\n addFiles(e.dataTransfer.files)\n }\n }\n },\n [addFiles, multiple]\n )\n\n const handleFileChange = useCallback(\n (e: ChangeEvent<HTMLInputElement>) => {\n if (e.target.files && e.target.files.length > 0) {\n addFiles(e.target.files)\n }\n },\n [addFiles]\n )\n\n const openFileDialog = useCallback(() => {\n if (inputRef.current) {\n inputRef.current.click()\n }\n }, [])\n\n const getInputProps = useCallback(\n (props: InputHTMLAttributes<HTMLInputElement> = {}) => {\n return {\n ...props,\n type: \"file\" as const,\n onChange: handleFileChange,\n accept: props.accept || accept,\n multiple: props.multiple !== undefined ? props.multiple : multiple,\n // Cast to `any` to prevent mismatched React ref type errors across workspaces\n ref: inputRef as any,\n }\n },\n [accept, multiple, handleFileChange]\n )\n\n return [\n state,\n {\n addFiles,\n removeFile,\n clearFiles,\n clearErrors,\n handleDragEnter,\n handleDragLeave,\n handleDragOver,\n handleDrop,\n handleFileChange,\n openFileDialog,\n getInputProps,\n },\n ]\n}\n\n// Helper function to format bytes to human-readable format\nexport const formatBytes = (bytes: number, decimals = 2): string => {\n if (bytes === 0) return \"0 Bytes\"\n\n const k = 1024\n const dm = decimals < 0 ? 0 : decimals\n const sizes = [\"Bytes\", \"KB\", \"MB\", \"GB\", \"TB\", \"PB\", \"EB\", \"ZB\", \"YB\"]\n\n const i = Math.floor(Math.log(bytes) / Math.log(k))\n\n return Number.parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + sizes[i]\n}\n",
|
|
17
|
+
"type": "registry:hook"
|
|
18
|
+
}
|
|
19
|
+
],
|
|
20
|
+
"meta": {
|
|
21
|
+
"tags": [
|
|
22
|
+
"upload",
|
|
23
|
+
"file",
|
|
24
|
+
"image",
|
|
25
|
+
"drag and drop"
|
|
26
|
+
],
|
|
27
|
+
"colSpan": 2
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "comp-549",
|
|
4
|
+
"type": "registry:component",
|
|
5
|
+
"registryDependencies": [
|
|
6
|
+
"https://loveui.dev/building-blocks/r/button.json"
|
|
7
|
+
],
|
|
8
|
+
"files": [
|
|
9
|
+
{
|
|
10
|
+
"path": "registry/default/components/comp-549.tsx",
|
|
11
|
+
"content": "\"use client\"\n\nimport {\n AlertCircleIcon,\n FileArchiveIcon,\n FileIcon,\n FileSpreadsheetIcon,\n FileTextIcon,\n FileUpIcon,\n HeadphonesIcon,\n ImageIcon,\n VideoIcon,\n XIcon,\n} from \"lucide-react\"\n\nimport {\n formatBytes,\n useFileUpload,\n} from \"@/registry/building-blocks/default/hooks/use-file-upload\"\nimport { Button } from \"@/registry/building-blocks/default/ui/button\"\n\n// Create some dummy initial files\nconst initialFiles = [\n {\n name: \"document.pdf\",\n size: 528737,\n type: \"application/pdf\",\n url: \"https://example.com/document.pdf\",\n id: \"document.pdf-1744638436563-8u5xuls\",\n },\n {\n name: \"intro.zip\",\n size: 252873,\n type: \"application/zip\",\n url: \"https://example.com/intro.zip\",\n id: \"intro.zip-1744638436563-8u5xuls\",\n },\n {\n name: \"conclusion.xlsx\",\n size: 352873,\n type: \"application/xlsx\",\n url: \"https://example.com/conclusion.xlsx\",\n id: \"conclusion.xlsx-1744638436563-8u5xuls\",\n },\n]\n\nconst getFileIcon = (file: { file: File | { type: string; name: string } }) => {\n const fileType = file.file instanceof File ? file.file.type : file.file.type\n const fileName = file.file instanceof File ? file.file.name : file.file.name\n\n if (\n fileType.includes(\"pdf\") ||\n fileName.endsWith(\".pdf\") ||\n fileType.includes(\"word\") ||\n fileName.endsWith(\".doc\") ||\n fileName.endsWith(\".docx\")\n ) {\n return <FileTextIcon className=\"size-4 opacity-60\" />\n } else if (\n fileType.includes(\"zip\") ||\n fileType.includes(\"archive\") ||\n fileName.endsWith(\".zip\") ||\n fileName.endsWith(\".rar\")\n ) {\n return <FileArchiveIcon className=\"size-4 opacity-60\" />\n } else if (\n fileType.includes(\"excel\") ||\n fileName.endsWith(\".xls\") ||\n fileName.endsWith(\".xlsx\")\n ) {\n return <FileSpreadsheetIcon className=\"size-4 opacity-60\" />\n } else if (fileType.includes(\"video/\")) {\n return <VideoIcon className=\"size-4 opacity-60\" />\n } else if (fileType.includes(\"audio/\")) {\n return <HeadphonesIcon className=\"size-4 opacity-60\" />\n } else if (fileType.startsWith(\"image/\")) {\n return <ImageIcon className=\"size-4 opacity-60\" />\n }\n return <FileIcon className=\"size-4 opacity-60\" />\n}\n\nexport default function Component() {\n const maxSize = 100 * 1024 * 1024 // 10MB default\n const maxFiles = 10\n\n const [\n { files, isDragging, errors },\n {\n handleDragEnter,\n handleDragLeave,\n handleDragOver,\n handleDrop,\n openFileDialog,\n removeFile,\n clearFiles,\n getInputProps,\n },\n ] = useFileUpload({\n multiple: true,\n maxFiles,\n maxSize,\n initialFiles,\n })\n\n return (\n <div className=\"flex flex-col gap-2\">\n {/* Drop area */}\n <div\n role=\"button\"\n onClick={openFileDialog}\n onDragEnter={handleDragEnter}\n onDragLeave={handleDragLeave}\n onDragOver={handleDragOver}\n onDrop={handleDrop}\n data-dragging={isDragging || undefined}\n className=\"flex min-h-40 flex-col items-center justify-center rounded-xl border border-dashed border-input p-4 transition-colors hover:bg-accent/50 has-disabled:pointer-events-none has-disabled:opacity-50 has-[input:focus]:border-ring has-[input:focus]:ring-[3px] has-[input:focus]:ring-ring/50 data-[dragging=true]:bg-accent/50\"\n >\n <input\n {...getInputProps()}\n className=\"sr-only\"\n aria-label=\"Upload files\"\n />\n\n <div className=\"flex flex-col items-center justify-center text-center\">\n <div\n className=\"mb-2 flex size-11 shrink-0 items-center justify-center rounded-full border bg-background\"\n aria-hidden=\"true\"\n >\n <FileUpIcon className=\"size-4 opacity-60\" />\n </div>\n <p className=\"mb-1.5 text-sm font-medium\">Upload files</p>\n <p className=\"mb-2 text-xs text-muted-foreground\">\n Drag & drop or click to browse\n </p>\n <div className=\"flex flex-wrap justify-center gap-1 text-xs text-muted-foreground/70\">\n <span>All files</span>\n <span>∙</span>\n <span>Max {maxFiles} files</span>\n <span>∙</span>\n <span>Up to {formatBytes(maxSize)}</span>\n </div>\n </div>\n </div>\n\n {errors.length > 0 && (\n <div\n className=\"flex items-center gap-1 text-xs text-destructive\"\n role=\"alert\"\n >\n <AlertCircleIcon className=\"size-3 shrink-0\" />\n <span>{errors[0]}</span>\n </div>\n )}\n\n {/* File list */}\n {files.length > 0 && (\n <div className=\"space-y-2\">\n {files.map((file) => (\n <div\n key={file.id}\n className=\"flex items-center justify-between gap-2 rounded-lg border bg-background p-2 pe-3\"\n >\n <div className=\"flex items-center gap-3 overflow-hidden\">\n <div className=\"flex aspect-square size-10 shrink-0 items-center justify-center rounded border\">\n {getFileIcon(file)}\n </div>\n <div className=\"flex min-w-0 flex-col gap-0.5\">\n <p className=\"truncate text-[13px] font-medium\">\n {file.file instanceof File\n ? file.file.name\n : file.file.name}\n </p>\n <p className=\"text-xs text-muted-foreground\">\n {formatBytes(\n file.file instanceof File\n ? file.file.size\n : file.file.size\n )}\n </p>\n </div>\n </div>\n\n <Button\n size=\"icon\"\n variant=\"ghost\"\n className=\"-me-2 size-8 text-muted-foreground/80 hover:bg-transparent hover:text-foreground\"\n onClick={() => removeFile(file.id)}\n aria-label=\"Remove file\"\n >\n <XIcon className=\"size-4\" aria-hidden=\"true\" />\n </Button>\n </div>\n ))}\n\n {/* Remove all files button */}\n {files.length > 1 && (\n <div>\n <Button size=\"sm\" variant=\"outline\" onClick={clearFiles}>\n Remove all files\n </Button>\n </div>\n )}\n </div>\n )}\n\n <p\n aria-live=\"polite\"\n role=\"region\"\n className=\"mt-2 text-center text-xs text-muted-foreground\"\n >\n Multiple files uploader w/ list ∙{\" \"}\n <a\n href=\"https://github.com/loveui/blob/main/apps/origin/docs/use-file-upload.md\"\n className=\"underline hover:text-foreground\"\n >\n API\n </a>\n </p>\n </div>\n )\n}\n",
|
|
12
|
+
"type": "registry:component"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"path": "registry/default/hooks/use-file-upload.ts",
|
|
16
|
+
"content": "\"use client\"\n\nimport type React from \"react\"\nimport {\n useCallback,\n useRef,\n useState,\n type ChangeEvent,\n type DragEvent,\n type InputHTMLAttributes,\n} from \"react\"\n\nexport type FileMetadata = {\n name: string\n size: number\n type: string\n url: string\n id: string\n}\n\nexport type FileWithPreview = {\n file: File | FileMetadata\n id: string\n preview?: string\n}\n\nexport type FileUploadOptions = {\n maxFiles?: number // Only used when multiple is true, defaults to Infinity\n maxSize?: number // in bytes\n accept?: string\n multiple?: boolean // Defaults to false\n initialFiles?: FileMetadata[]\n onFilesChange?: (files: FileWithPreview[]) => void // Callback when files change\n onFilesAdded?: (addedFiles: FileWithPreview[]) => void // Callback when new files are added\n}\n\nexport type FileUploadState = {\n files: FileWithPreview[]\n isDragging: boolean\n errors: string[]\n}\n\nexport type FileUploadActions = {\n addFiles: (files: FileList | File[]) => void\n removeFile: (id: string) => void\n clearFiles: () => void\n clearErrors: () => void\n handleDragEnter: (e: DragEvent<HTMLElement>) => void\n handleDragLeave: (e: DragEvent<HTMLElement>) => void\n handleDragOver: (e: DragEvent<HTMLElement>) => void\n handleDrop: (e: DragEvent<HTMLElement>) => void\n handleFileChange: (e: ChangeEvent<HTMLInputElement>) => void\n openFileDialog: () => void\n getInputProps: (\n props?: InputHTMLAttributes<HTMLInputElement>\n ) => InputHTMLAttributes<HTMLInputElement> & {\n // Use `any` here to avoid cross-React ref type conflicts across packages\n ref: any\n }\n}\n\nexport const useFileUpload = (\n options: FileUploadOptions = {}\n): [FileUploadState, FileUploadActions] => {\n const {\n maxFiles = Infinity,\n maxSize = Infinity,\n accept = \"*\",\n multiple = false,\n initialFiles = [],\n onFilesChange,\n onFilesAdded,\n } = options\n\n const [state, setState] = useState<FileUploadState>({\n files: initialFiles.map((file) => ({\n file,\n id: file.id,\n preview: file.url,\n })),\n isDragging: false,\n errors: [],\n })\n\n const inputRef = useRef<HTMLInputElement>(null)\n\n const validateFile = useCallback(\n (file: File | FileMetadata): string | null => {\n if (file instanceof File) {\n if (file.size > maxSize) {\n return `File \"${file.name}\" exceeds the maximum size of ${formatBytes(maxSize)}.`\n }\n } else {\n if (file.size > maxSize) {\n return `File \"${file.name}\" exceeds the maximum size of ${formatBytes(maxSize)}.`\n }\n }\n\n if (accept !== \"*\") {\n const acceptedTypes = accept.split(\",\").map((type) => type.trim())\n const fileType = file instanceof File ? file.type || \"\" : file.type\n const fileExtension = `.${file instanceof File ? file.name.split(\".\").pop() : file.name.split(\".\").pop()}`\n\n const isAccepted = acceptedTypes.some((type) => {\n if (type.startsWith(\".\")) {\n return fileExtension.toLowerCase() === type.toLowerCase()\n }\n if (type.endsWith(\"/*\")) {\n const baseType = type.split(\"/\")[0]\n return fileType.startsWith(`${baseType}/`)\n }\n return fileType === type\n })\n\n if (!isAccepted) {\n return `File \"${file instanceof File ? file.name : file.name}\" is not an accepted file type.`\n }\n }\n\n return null\n },\n [accept, maxSize]\n )\n\n const createPreview = useCallback(\n (file: File | FileMetadata): string | undefined => {\n if (file instanceof File) {\n return URL.createObjectURL(file)\n }\n return file.url\n },\n []\n )\n\n const generateUniqueId = useCallback((file: File | FileMetadata): string => {\n if (file instanceof File) {\n return `${file.name}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`\n }\n return file.id\n }, [])\n\n const clearFiles = useCallback(() => {\n setState((prev) => {\n // Clean up object URLs\n prev.files.forEach((file) => {\n if (\n file.preview &&\n file.file instanceof File &&\n file.file.type.startsWith(\"image/\")\n ) {\n URL.revokeObjectURL(file.preview)\n }\n })\n\n if (inputRef.current) {\n inputRef.current.value = \"\"\n }\n\n const newState = {\n ...prev,\n files: [],\n errors: [],\n }\n\n onFilesChange?.(newState.files)\n return newState\n })\n }, [onFilesChange])\n\n const addFiles = useCallback(\n (newFiles: FileList | File[]) => {\n if (!newFiles || newFiles.length === 0) return\n\n const newFilesArray = Array.from(newFiles)\n const errors: string[] = []\n\n // Clear existing errors when new files are uploaded\n setState((prev) => ({ ...prev, errors: [] }))\n\n // In single file mode, clear existing files first\n if (!multiple) {\n clearFiles()\n }\n\n // Check if adding these files would exceed maxFiles (only in multiple mode)\n if (\n multiple &&\n maxFiles !== Infinity &&\n state.files.length + newFilesArray.length > maxFiles\n ) {\n errors.push(`You can only upload a maximum of ${maxFiles} files.`)\n setState((prev) => ({ ...prev, errors }))\n return\n }\n\n const validFiles: FileWithPreview[] = []\n\n newFilesArray.forEach((file) => {\n // Only check for duplicates if multiple files are allowed\n if (multiple) {\n const isDuplicate = state.files.some(\n (existingFile) =>\n existingFile.file.name === file.name &&\n existingFile.file.size === file.size\n )\n\n // Skip duplicate files silently\n if (isDuplicate) {\n return\n }\n }\n\n // Check file size\n if (file.size > maxSize) {\n errors.push(\n multiple\n ? `Some files exceed the maximum size of ${formatBytes(maxSize)}.`\n : `File exceeds the maximum size of ${formatBytes(maxSize)}.`\n )\n return\n }\n\n const error = validateFile(file)\n if (error) {\n errors.push(error)\n } else {\n validFiles.push({\n file,\n id: generateUniqueId(file),\n preview: createPreview(file),\n })\n }\n })\n\n // Only update state if we have valid files to add\n if (validFiles.length > 0) {\n // Call the onFilesAdded callback with the newly added valid files\n onFilesAdded?.(validFiles)\n\n setState((prev) => {\n const newFiles = !multiple\n ? validFiles\n : [...prev.files, ...validFiles]\n onFilesChange?.(newFiles)\n return {\n ...prev,\n files: newFiles,\n errors,\n }\n })\n } else if (errors.length > 0) {\n setState((prev) => ({\n ...prev,\n errors,\n }))\n }\n\n // Reset input value after handling files\n if (inputRef.current) {\n inputRef.current.value = \"\"\n }\n },\n [\n state.files,\n maxFiles,\n multiple,\n maxSize,\n validateFile,\n createPreview,\n generateUniqueId,\n clearFiles,\n onFilesChange,\n onFilesAdded,\n ]\n )\n\n const removeFile = useCallback(\n (id: string) => {\n setState((prev) => {\n const fileToRemove = prev.files.find((file) => file.id === id)\n if (\n fileToRemove &&\n fileToRemove.preview &&\n fileToRemove.file instanceof File &&\n fileToRemove.file.type.startsWith(\"image/\")\n ) {\n URL.revokeObjectURL(fileToRemove.preview)\n }\n\n const newFiles = prev.files.filter((file) => file.id !== id)\n onFilesChange?.(newFiles)\n\n return {\n ...prev,\n files: newFiles,\n errors: [],\n }\n })\n },\n [onFilesChange]\n )\n\n const clearErrors = useCallback(() => {\n setState((prev) => ({\n ...prev,\n errors: [],\n }))\n }, [])\n\n const handleDragEnter = useCallback((e: DragEvent<HTMLElement>) => {\n e.preventDefault()\n e.stopPropagation()\n setState((prev) => ({ ...prev, isDragging: true }))\n }, [])\n\n const handleDragLeave = useCallback((e: DragEvent<HTMLElement>) => {\n e.preventDefault()\n e.stopPropagation()\n\n if (e.currentTarget.contains(e.relatedTarget as Node)) {\n return\n }\n\n setState((prev) => ({ ...prev, isDragging: false }))\n }, [])\n\n const handleDragOver = useCallback((e: DragEvent<HTMLElement>) => {\n e.preventDefault()\n e.stopPropagation()\n }, [])\n\n const handleDrop = useCallback(\n (e: DragEvent<HTMLElement>) => {\n e.preventDefault()\n e.stopPropagation()\n setState((prev) => ({ ...prev, isDragging: false }))\n\n // Don't process files if the input is disabled\n if (inputRef.current?.disabled) {\n return\n }\n\n if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {\n // In single file mode, only use the first file\n if (!multiple) {\n const file = e.dataTransfer.files[0]\n addFiles([file])\n } else {\n addFiles(e.dataTransfer.files)\n }\n }\n },\n [addFiles, multiple]\n )\n\n const handleFileChange = useCallback(\n (e: ChangeEvent<HTMLInputElement>) => {\n if (e.target.files && e.target.files.length > 0) {\n addFiles(e.target.files)\n }\n },\n [addFiles]\n )\n\n const openFileDialog = useCallback(() => {\n if (inputRef.current) {\n inputRef.current.click()\n }\n }, [])\n\n const getInputProps = useCallback(\n (props: InputHTMLAttributes<HTMLInputElement> = {}) => {\n return {\n ...props,\n type: \"file\" as const,\n onChange: handleFileChange,\n accept: props.accept || accept,\n multiple: props.multiple !== undefined ? props.multiple : multiple,\n // Cast to `any` to prevent mismatched React ref type errors across workspaces\n ref: inputRef as any,\n }\n },\n [accept, multiple, handleFileChange]\n )\n\n return [\n state,\n {\n addFiles,\n removeFile,\n clearFiles,\n clearErrors,\n handleDragEnter,\n handleDragLeave,\n handleDragOver,\n handleDrop,\n handleFileChange,\n openFileDialog,\n getInputProps,\n },\n ]\n}\n\n// Helper function to format bytes to human-readable format\nexport const formatBytes = (bytes: number, decimals = 2): string => {\n if (bytes === 0) return \"0 Bytes\"\n\n const k = 1024\n const dm = decimals < 0 ? 0 : decimals\n const sizes = [\"Bytes\", \"KB\", \"MB\", \"GB\", \"TB\", \"PB\", \"EB\", \"ZB\", \"YB\"]\n\n const i = Math.floor(Math.log(bytes) / Math.log(k))\n\n return Number.parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + sizes[i]\n}\n",
|
|
17
|
+
"type": "registry:hook"
|
|
18
|
+
}
|
|
19
|
+
],
|
|
20
|
+
"meta": {
|
|
21
|
+
"tags": [
|
|
22
|
+
"upload",
|
|
23
|
+
"file",
|
|
24
|
+
"image",
|
|
25
|
+
"drag and drop"
|
|
26
|
+
],
|
|
27
|
+
"colSpan": 2
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "comp-55",
|
|
4
|
+
"type": "registry:component",
|
|
5
|
+
"dependencies": [
|
|
6
|
+
"use-mask-input"
|
|
7
|
+
],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"https://loveui.dev/building-blocks/r/input.json",
|
|
10
|
+
"https://loveui.dev/building-blocks/r/label.json"
|
|
11
|
+
],
|
|
12
|
+
"files": [
|
|
13
|
+
{
|
|
14
|
+
"path": "registry/default/components/comp-55.tsx",
|
|
15
|
+
"content": "\"use client\"\n\nimport { useId } from \"react\"\nimport { withMask } from \"use-mask-input\"\n\nimport { Input } from \"@/registry/building-blocks/default/ui/input\"\nimport { Label } from \"@/registry/building-blocks/default/ui/label\"\n\nexport default function Component() {\n const id = useId()\n return (\n <div className=\"*:not-first:mt-2\">\n <Label htmlFor={id}>Timestamp</Label>\n <Input\n id={id}\n placeholder=\"00:00:00\"\n type=\"text\"\n ref={(input) => {\n if (input) {\n withMask(\"99:99:99\", {\n placeholder: \"-\",\n showMaskOnHover: false,\n })(input)\n }\n }}\n />\n <p\n className=\"mt-2 text-xs text-muted-foreground\"\n role=\"region\"\n aria-live=\"polite\"\n >\n Built with{\" \"}\n <a\n className=\"underline hover:text-foreground\"\n href=\"https://github.com/eduardoborges/use-mask-input\"\n target=\"_blank\"\n rel=\"noopener nofollow\"\n >\n use-mask-input\n </a>\n </p>\n </div>\n )\n}\n",
|
|
16
|
+
"type": "registry:component"
|
|
17
|
+
}
|
|
18
|
+
],
|
|
19
|
+
"meta": {
|
|
20
|
+
"tags": [
|
|
21
|
+
"input",
|
|
22
|
+
"label",
|
|
23
|
+
"mask",
|
|
24
|
+
"time"
|
|
25
|
+
]
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "comp-550",
|
|
4
|
+
"type": "registry:component",
|
|
5
|
+
"registryDependencies": [
|
|
6
|
+
"https://loveui.dev/building-blocks/r/button.json"
|
|
7
|
+
],
|
|
8
|
+
"files": [
|
|
9
|
+
{
|
|
10
|
+
"path": "registry/default/components/comp-550.tsx",
|
|
11
|
+
"content": "\"use client\"\n\nimport type React from \"react\"\nimport {\n AlertCircleIcon,\n FileArchiveIcon,\n FileIcon,\n FileSpreadsheetIcon,\n FileTextIcon,\n HeadphonesIcon,\n ImageIcon,\n Trash2Icon,\n UploadIcon,\n VideoIcon,\n XIcon,\n} from \"lucide-react\"\n\nimport {\n formatBytes,\n useFileUpload,\n} from \"@/registry/building-blocks/default/hooks/use-file-upload\"\nimport { Button } from \"@/registry/building-blocks/default/ui/button\"\n\nconst getFileIcon = (file: { file: File | { type: string; name: string } }) => {\n const fileType = file.file instanceof File ? file.file.type : file.file.type\n const fileName = file.file instanceof File ? file.file.name : file.file.name\n\n if (\n fileType.includes(\"pdf\") ||\n fileName.endsWith(\".pdf\") ||\n fileType.includes(\"word\") ||\n fileName.endsWith(\".doc\") ||\n fileName.endsWith(\".docx\")\n ) {\n return <FileTextIcon className=\"size-4 opacity-60\" />\n } else if (\n fileType.includes(\"zip\") ||\n fileType.includes(\"archive\") ||\n fileName.endsWith(\".zip\") ||\n fileName.endsWith(\".rar\")\n ) {\n return <FileArchiveIcon className=\"size-4 opacity-60\" />\n } else if (\n fileType.includes(\"excel\") ||\n fileName.endsWith(\".xls\") ||\n fileName.endsWith(\".xlsx\")\n ) {\n return <FileSpreadsheetIcon className=\"size-4 opacity-60\" />\n } else if (fileType.includes(\"video/\")) {\n return <VideoIcon className=\"size-4 opacity-60\" />\n } else if (fileType.includes(\"audio/\")) {\n return <HeadphonesIcon className=\"size-4 opacity-60\" />\n } else if (fileType.startsWith(\"image/\")) {\n return <ImageIcon className=\"size-4 opacity-60\" />\n }\n return <FileIcon className=\"size-4 opacity-60\" />\n}\n\n// Create some dummy initial files\nconst initialFiles = [\n {\n name: \"document.pdf\",\n size: 528737,\n type: \"application/pdf\",\n url: \"https://example.com/document.pdf\",\n id: \"document.pdf-1744638436563-8u5xuls\",\n },\n {\n name: \"intro.zip\",\n size: 252873,\n type: \"application/zip\",\n url: \"https://example.com/intro.zip\",\n id: \"intro.zip-1744638436563-8u5xuls\",\n },\n {\n name: \"conclusion.xlsx\",\n size: 352873,\n type: \"application/xlsx\",\n url: \"https://example.com/conclusion.xlsx\",\n id: \"conclusion.xlsx-1744638436563-8u5xuls\",\n },\n]\n\nexport default function Component() {\n const maxSize = 10 * 1024 * 1024 // 10MB default\n const maxFiles = 10\n\n const [\n { files, isDragging, errors },\n {\n handleDragEnter,\n handleDragLeave,\n handleDragOver,\n handleDrop,\n openFileDialog,\n removeFile,\n clearFiles,\n getInputProps,\n },\n ] = useFileUpload({\n multiple: true,\n maxFiles,\n maxSize,\n initialFiles,\n })\n\n return (\n <div className=\"flex flex-col gap-2\">\n {/* Drop area */}\n <div\n onDragEnter={handleDragEnter}\n onDragLeave={handleDragLeave}\n onDragOver={handleDragOver}\n onDrop={handleDrop}\n data-dragging={isDragging || undefined}\n data-files={files.length > 0 || undefined}\n className=\"flex min-h-56 flex-col items-center rounded-xl border border-dashed border-input p-4 transition-colors not-data-[files]:justify-center has-[input:focus]:border-ring has-[input:focus]:ring-[3px] has-[input:focus]:ring-ring/50 data-[dragging=true]:bg-accent/50\"\n >\n <input\n {...getInputProps()}\n className=\"sr-only\"\n aria-label=\"Upload files\"\n />\n\n {files.length > 0 ? (\n <div className=\"flex w-full flex-col gap-3\">\n <div className=\"flex items-center justify-between gap-2\">\n <h3 className=\"truncate text-sm font-medium\">\n Uploaded Files ({files.length})\n </h3>\n <Button variant=\"outline\" size=\"sm\" onClick={clearFiles}>\n <Trash2Icon\n className=\"-ms-0.5 size-3.5 opacity-60\"\n aria-hidden=\"true\"\n />\n Remove all\n </Button>\n </div>\n <div className=\"w-full space-y-2\">\n {files.map((file) => (\n <div\n key={file.id}\n className=\"flex items-center justify-between gap-2 rounded-lg border bg-background p-2 pe-3\"\n >\n <div className=\"flex items-center gap-3 overflow-hidden\">\n <div className=\"flex aspect-square size-10 shrink-0 items-center justify-center rounded border\">\n {getFileIcon(file)}\n </div>\n <div className=\"flex min-w-0 flex-col gap-0.5\">\n <p className=\"truncate text-[13px] font-medium\">\n {file.file instanceof File\n ? file.file.name\n : file.file.name}\n </p>\n <p className=\"text-xs text-muted-foreground\">\n {formatBytes(\n file.file instanceof File\n ? file.file.size\n : file.file.size\n )}\n </p>\n </div>\n </div>\n\n <Button\n size=\"icon\"\n variant=\"ghost\"\n className=\"-me-2 size-8 text-muted-foreground/80 hover:bg-transparent hover:text-foreground\"\n onClick={() => removeFile(file.id)}\n aria-label=\"Remove file\"\n >\n <XIcon className=\"size-4\" aria-hidden=\"true\" />\n </Button>\n </div>\n ))}\n\n {files.length < maxFiles && (\n <Button\n variant=\"outline\"\n className=\"mt-2 w-full\"\n onClick={openFileDialog}\n >\n <UploadIcon className=\"-ms-1 opacity-60\" aria-hidden=\"true\" />\n Add more\n </Button>\n )}\n </div>\n </div>\n ) : (\n <div className=\"flex flex-col items-center justify-center text-center\">\n <div\n className=\"mb-2 flex size-11 shrink-0 items-center justify-center rounded-full border bg-background\"\n aria-hidden=\"true\"\n >\n <FileIcon className=\"size-4 opacity-60\" />\n </div>\n <p className=\"mb-1.5 text-sm font-medium\">Upload files</p>\n <p className=\"text-xs text-muted-foreground\">\n Max {maxFiles} files ∙ Up to {maxSize}MB\n </p>\n <Button variant=\"outline\" className=\"mt-4\" onClick={openFileDialog}>\n <UploadIcon className=\"-ms-1 opacity-60\" aria-hidden=\"true\" />\n Select files\n </Button>\n </div>\n )}\n </div>\n\n {errors.length > 0 && (\n <div\n className=\"flex items-center gap-1 text-xs text-destructive\"\n role=\"alert\"\n >\n <AlertCircleIcon className=\"size-3 shrink-0\" />\n <span>{errors[0]}</span>\n </div>\n )}\n\n <p\n aria-live=\"polite\"\n role=\"region\"\n className=\"mt-2 text-center text-xs text-muted-foreground\"\n >\n Multiple files uploader w/ list inside ∙{\" \"}\n <a\n href=\"https://github.com/loveui/blob/main/apps/origin/docs/use-file-upload.md\"\n className=\"underline hover:text-foreground\"\n >\n API\n </a>\n </p>\n </div>\n )\n}\n",
|
|
12
|
+
"type": "registry:component"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"path": "registry/default/hooks/use-file-upload.ts",
|
|
16
|
+
"content": "\"use client\"\n\nimport type React from \"react\"\nimport {\n useCallback,\n useRef,\n useState,\n type ChangeEvent,\n type DragEvent,\n type InputHTMLAttributes,\n} from \"react\"\n\nexport type FileMetadata = {\n name: string\n size: number\n type: string\n url: string\n id: string\n}\n\nexport type FileWithPreview = {\n file: File | FileMetadata\n id: string\n preview?: string\n}\n\nexport type FileUploadOptions = {\n maxFiles?: number // Only used when multiple is true, defaults to Infinity\n maxSize?: number // in bytes\n accept?: string\n multiple?: boolean // Defaults to false\n initialFiles?: FileMetadata[]\n onFilesChange?: (files: FileWithPreview[]) => void // Callback when files change\n onFilesAdded?: (addedFiles: FileWithPreview[]) => void // Callback when new files are added\n}\n\nexport type FileUploadState = {\n files: FileWithPreview[]\n isDragging: boolean\n errors: string[]\n}\n\nexport type FileUploadActions = {\n addFiles: (files: FileList | File[]) => void\n removeFile: (id: string) => void\n clearFiles: () => void\n clearErrors: () => void\n handleDragEnter: (e: DragEvent<HTMLElement>) => void\n handleDragLeave: (e: DragEvent<HTMLElement>) => void\n handleDragOver: (e: DragEvent<HTMLElement>) => void\n handleDrop: (e: DragEvent<HTMLElement>) => void\n handleFileChange: (e: ChangeEvent<HTMLInputElement>) => void\n openFileDialog: () => void\n getInputProps: (\n props?: InputHTMLAttributes<HTMLInputElement>\n ) => InputHTMLAttributes<HTMLInputElement> & {\n // Use `any` here to avoid cross-React ref type conflicts across packages\n ref: any\n }\n}\n\nexport const useFileUpload = (\n options: FileUploadOptions = {}\n): [FileUploadState, FileUploadActions] => {\n const {\n maxFiles = Infinity,\n maxSize = Infinity,\n accept = \"*\",\n multiple = false,\n initialFiles = [],\n onFilesChange,\n onFilesAdded,\n } = options\n\n const [state, setState] = useState<FileUploadState>({\n files: initialFiles.map((file) => ({\n file,\n id: file.id,\n preview: file.url,\n })),\n isDragging: false,\n errors: [],\n })\n\n const inputRef = useRef<HTMLInputElement>(null)\n\n const validateFile = useCallback(\n (file: File | FileMetadata): string | null => {\n if (file instanceof File) {\n if (file.size > maxSize) {\n return `File \"${file.name}\" exceeds the maximum size of ${formatBytes(maxSize)}.`\n }\n } else {\n if (file.size > maxSize) {\n return `File \"${file.name}\" exceeds the maximum size of ${formatBytes(maxSize)}.`\n }\n }\n\n if (accept !== \"*\") {\n const acceptedTypes = accept.split(\",\").map((type) => type.trim())\n const fileType = file instanceof File ? file.type || \"\" : file.type\n const fileExtension = `.${file instanceof File ? file.name.split(\".\").pop() : file.name.split(\".\").pop()}`\n\n const isAccepted = acceptedTypes.some((type) => {\n if (type.startsWith(\".\")) {\n return fileExtension.toLowerCase() === type.toLowerCase()\n }\n if (type.endsWith(\"/*\")) {\n const baseType = type.split(\"/\")[0]\n return fileType.startsWith(`${baseType}/`)\n }\n return fileType === type\n })\n\n if (!isAccepted) {\n return `File \"${file instanceof File ? file.name : file.name}\" is not an accepted file type.`\n }\n }\n\n return null\n },\n [accept, maxSize]\n )\n\n const createPreview = useCallback(\n (file: File | FileMetadata): string | undefined => {\n if (file instanceof File) {\n return URL.createObjectURL(file)\n }\n return file.url\n },\n []\n )\n\n const generateUniqueId = useCallback((file: File | FileMetadata): string => {\n if (file instanceof File) {\n return `${file.name}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`\n }\n return file.id\n }, [])\n\n const clearFiles = useCallback(() => {\n setState((prev) => {\n // Clean up object URLs\n prev.files.forEach((file) => {\n if (\n file.preview &&\n file.file instanceof File &&\n file.file.type.startsWith(\"image/\")\n ) {\n URL.revokeObjectURL(file.preview)\n }\n })\n\n if (inputRef.current) {\n inputRef.current.value = \"\"\n }\n\n const newState = {\n ...prev,\n files: [],\n errors: [],\n }\n\n onFilesChange?.(newState.files)\n return newState\n })\n }, [onFilesChange])\n\n const addFiles = useCallback(\n (newFiles: FileList | File[]) => {\n if (!newFiles || newFiles.length === 0) return\n\n const newFilesArray = Array.from(newFiles)\n const errors: string[] = []\n\n // Clear existing errors when new files are uploaded\n setState((prev) => ({ ...prev, errors: [] }))\n\n // In single file mode, clear existing files first\n if (!multiple) {\n clearFiles()\n }\n\n // Check if adding these files would exceed maxFiles (only in multiple mode)\n if (\n multiple &&\n maxFiles !== Infinity &&\n state.files.length + newFilesArray.length > maxFiles\n ) {\n errors.push(`You can only upload a maximum of ${maxFiles} files.`)\n setState((prev) => ({ ...prev, errors }))\n return\n }\n\n const validFiles: FileWithPreview[] = []\n\n newFilesArray.forEach((file) => {\n // Only check for duplicates if multiple files are allowed\n if (multiple) {\n const isDuplicate = state.files.some(\n (existingFile) =>\n existingFile.file.name === file.name &&\n existingFile.file.size === file.size\n )\n\n // Skip duplicate files silently\n if (isDuplicate) {\n return\n }\n }\n\n // Check file size\n if (file.size > maxSize) {\n errors.push(\n multiple\n ? `Some files exceed the maximum size of ${formatBytes(maxSize)}.`\n : `File exceeds the maximum size of ${formatBytes(maxSize)}.`\n )\n return\n }\n\n const error = validateFile(file)\n if (error) {\n errors.push(error)\n } else {\n validFiles.push({\n file,\n id: generateUniqueId(file),\n preview: createPreview(file),\n })\n }\n })\n\n // Only update state if we have valid files to add\n if (validFiles.length > 0) {\n // Call the onFilesAdded callback with the newly added valid files\n onFilesAdded?.(validFiles)\n\n setState((prev) => {\n const newFiles = !multiple\n ? validFiles\n : [...prev.files, ...validFiles]\n onFilesChange?.(newFiles)\n return {\n ...prev,\n files: newFiles,\n errors,\n }\n })\n } else if (errors.length > 0) {\n setState((prev) => ({\n ...prev,\n errors,\n }))\n }\n\n // Reset input value after handling files\n if (inputRef.current) {\n inputRef.current.value = \"\"\n }\n },\n [\n state.files,\n maxFiles,\n multiple,\n maxSize,\n validateFile,\n createPreview,\n generateUniqueId,\n clearFiles,\n onFilesChange,\n onFilesAdded,\n ]\n )\n\n const removeFile = useCallback(\n (id: string) => {\n setState((prev) => {\n const fileToRemove = prev.files.find((file) => file.id === id)\n if (\n fileToRemove &&\n fileToRemove.preview &&\n fileToRemove.file instanceof File &&\n fileToRemove.file.type.startsWith(\"image/\")\n ) {\n URL.revokeObjectURL(fileToRemove.preview)\n }\n\n const newFiles = prev.files.filter((file) => file.id !== id)\n onFilesChange?.(newFiles)\n\n return {\n ...prev,\n files: newFiles,\n errors: [],\n }\n })\n },\n [onFilesChange]\n )\n\n const clearErrors = useCallback(() => {\n setState((prev) => ({\n ...prev,\n errors: [],\n }))\n }, [])\n\n const handleDragEnter = useCallback((e: DragEvent<HTMLElement>) => {\n e.preventDefault()\n e.stopPropagation()\n setState((prev) => ({ ...prev, isDragging: true }))\n }, [])\n\n const handleDragLeave = useCallback((e: DragEvent<HTMLElement>) => {\n e.preventDefault()\n e.stopPropagation()\n\n if (e.currentTarget.contains(e.relatedTarget as Node)) {\n return\n }\n\n setState((prev) => ({ ...prev, isDragging: false }))\n }, [])\n\n const handleDragOver = useCallback((e: DragEvent<HTMLElement>) => {\n e.preventDefault()\n e.stopPropagation()\n }, [])\n\n const handleDrop = useCallback(\n (e: DragEvent<HTMLElement>) => {\n e.preventDefault()\n e.stopPropagation()\n setState((prev) => ({ ...prev, isDragging: false }))\n\n // Don't process files if the input is disabled\n if (inputRef.current?.disabled) {\n return\n }\n\n if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {\n // In single file mode, only use the first file\n if (!multiple) {\n const file = e.dataTransfer.files[0]\n addFiles([file])\n } else {\n addFiles(e.dataTransfer.files)\n }\n }\n },\n [addFiles, multiple]\n )\n\n const handleFileChange = useCallback(\n (e: ChangeEvent<HTMLInputElement>) => {\n if (e.target.files && e.target.files.length > 0) {\n addFiles(e.target.files)\n }\n },\n [addFiles]\n )\n\n const openFileDialog = useCallback(() => {\n if (inputRef.current) {\n inputRef.current.click()\n }\n }, [])\n\n const getInputProps = useCallback(\n (props: InputHTMLAttributes<HTMLInputElement> = {}) => {\n return {\n ...props,\n type: \"file\" as const,\n onChange: handleFileChange,\n accept: props.accept || accept,\n multiple: props.multiple !== undefined ? props.multiple : multiple,\n // Cast to `any` to prevent mismatched React ref type errors across workspaces\n ref: inputRef as any,\n }\n },\n [accept, multiple, handleFileChange]\n )\n\n return [\n state,\n {\n addFiles,\n removeFile,\n clearFiles,\n clearErrors,\n handleDragEnter,\n handleDragLeave,\n handleDragOver,\n handleDrop,\n handleFileChange,\n openFileDialog,\n getInputProps,\n },\n ]\n}\n\n// Helper function to format bytes to human-readable format\nexport const formatBytes = (bytes: number, decimals = 2): string => {\n if (bytes === 0) return \"0 Bytes\"\n\n const k = 1024\n const dm = decimals < 0 ? 0 : decimals\n const sizes = [\"Bytes\", \"KB\", \"MB\", \"GB\", \"TB\", \"PB\", \"EB\", \"ZB\", \"YB\"]\n\n const i = Math.floor(Math.log(bytes) / Math.log(k))\n\n return Number.parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + sizes[i]\n}\n",
|
|
17
|
+
"type": "registry:hook"
|
|
18
|
+
}
|
|
19
|
+
],
|
|
20
|
+
"meta": {
|
|
21
|
+
"tags": [
|
|
22
|
+
"upload",
|
|
23
|
+
"file",
|
|
24
|
+
"image",
|
|
25
|
+
"drag and drop"
|
|
26
|
+
],
|
|
27
|
+
"colSpan": 2
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "comp-551",
|
|
4
|
+
"type": "registry:component",
|
|
5
|
+
"registryDependencies": [
|
|
6
|
+
"https://loveui.dev/building-blocks/r/button.json",
|
|
7
|
+
"https://loveui.dev/building-blocks/r/table.json"
|
|
8
|
+
],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "registry/default/components/comp-551.tsx",
|
|
12
|
+
"content": "\"use client\"\n\nimport {\n AlertCircleIcon,\n DownloadIcon,\n FileArchiveIcon,\n FileIcon,\n FileSpreadsheetIcon,\n FileTextIcon,\n HeadphonesIcon,\n ImageIcon,\n Trash2Icon,\n UploadCloudIcon,\n UploadIcon,\n VideoIcon,\n} from \"lucide-react\"\n\nimport {\n formatBytes,\n useFileUpload,\n} from \"@/registry/building-blocks/default/hooks/use-file-upload\"\nimport { Button } from \"@/registry/building-blocks/default/ui/button\"\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from \"@/registry/building-blocks/default/ui/table\"\n\n// Create some dummy initial files\nconst initialFiles = [\n {\n name: \"document.pdf\",\n size: 528737,\n type: \"application/pdf\",\n url: \"https://loveui.dev/building-blocks\",\n id: \"document.pdf-1744638436563-8u5xuls\",\n },\n {\n name: \"intro.zip\",\n size: 252873,\n type: \"application/zip\",\n url: \"https://loveui.dev/building-blocks\",\n id: \"intro.zip-1744638436563-8u5xuls\",\n },\n {\n name: \"conclusion.xlsx\",\n size: 352873,\n type: \"application/xlsx\",\n url: \"https://loveui.dev/building-blocks\",\n id: \"conclusion.xlsx-1744638436563-8u5xuls\",\n },\n]\n\nconst getFileIcon = (file: { file: File | { type: string; name: string } }) => {\n const fileType = file.file instanceof File ? file.file.type : file.file.type\n const fileName = file.file instanceof File ? file.file.name : file.file.name\n\n if (\n fileType.includes(\"pdf\") ||\n fileName.endsWith(\".pdf\") ||\n fileType.includes(\"word\") ||\n fileName.endsWith(\".doc\") ||\n fileName.endsWith(\".docx\")\n ) {\n return <FileTextIcon className=\"size-4 opacity-60\" />\n } else if (\n fileType.includes(\"zip\") ||\n fileType.includes(\"archive\") ||\n fileName.endsWith(\".zip\") ||\n fileName.endsWith(\".rar\")\n ) {\n return <FileArchiveIcon className=\"size-4 opacity-60\" />\n } else if (\n fileType.includes(\"excel\") ||\n fileName.endsWith(\".xls\") ||\n fileName.endsWith(\".xlsx\")\n ) {\n return <FileSpreadsheetIcon className=\"size-4 opacity-60\" />\n } else if (fileType.includes(\"video/\")) {\n return <VideoIcon className=\"size-4 opacity-60\" />\n } else if (fileType.includes(\"audio/\")) {\n return <HeadphonesIcon className=\"size-4 opacity-60\" />\n } else if (fileType.startsWith(\"image/\")) {\n return <ImageIcon className=\"size-4 opacity-60\" />\n }\n return <FileIcon className=\"size-4 opacity-60\" />\n}\n\nexport default function Component() {\n const maxSize = 10 * 1024 * 1024 // 10MB default\n const maxFiles = 10\n\n const [\n { files, isDragging, errors },\n {\n handleDragEnter,\n handleDragLeave,\n handleDragOver,\n handleDrop,\n openFileDialog,\n removeFile,\n clearFiles,\n getInputProps,\n },\n ] = useFileUpload({\n multiple: true,\n maxFiles,\n maxSize,\n initialFiles,\n })\n\n return (\n <div className=\"flex flex-col gap-2\">\n {/* Drop area */}\n <div\n onDragEnter={handleDragEnter}\n onDragLeave={handleDragLeave}\n onDragOver={handleDragOver}\n onDrop={handleDrop}\n data-dragging={isDragging || undefined}\n data-files={files.length > 0 || undefined}\n className=\"flex min-h-56 flex-col items-center rounded-xl border border-dashed border-input p-4 transition-colors not-data-[files]:justify-center has-[input:focus]:border-ring has-[input:focus]:ring-[3px] has-[input:focus]:ring-ring/50 data-[dragging=true]:bg-accent/50 data-[files]:hidden\"\n >\n <input\n {...getInputProps()}\n className=\"sr-only\"\n aria-label=\"Upload files\"\n />\n <div className=\"flex flex-col items-center justify-center text-center\">\n <div\n className=\"mb-2 flex size-11 shrink-0 items-center justify-center rounded-full border bg-background\"\n aria-hidden=\"true\"\n >\n <FileIcon className=\"size-4 opacity-60\" />\n </div>\n <p className=\"mb-1.5 text-sm font-medium\">Upload files</p>\n <p className=\"text-xs text-muted-foreground\">\n Max {maxFiles} files ∙ Up to {formatBytes(maxSize)}\n </p>\n <Button variant=\"outline\" className=\"mt-4\" onClick={openFileDialog}>\n <UploadIcon className=\"-ms-1 opacity-60\" aria-hidden=\"true\" />\n Select files\n </Button>\n </div>\n </div>\n {files.length > 0 && (\n <>\n {/* Table with files */}\n <div className=\"flex items-center justify-between gap-2\">\n <h3 className=\"text-sm font-medium\">Files ({files.length})</h3>\n <div className=\"flex gap-2\">\n <Button variant=\"outline\" size=\"sm\" onClick={openFileDialog}>\n <UploadCloudIcon\n className=\"-ms-0.5 size-3.5 opacity-60\"\n aria-hidden=\"true\"\n />\n Add files\n </Button>\n <Button variant=\"outline\" size=\"sm\" onClick={clearFiles}>\n <Trash2Icon\n className=\"-ms-0.5 size-3.5 opacity-60\"\n aria-hidden=\"true\"\n />\n Remove all\n </Button>\n </div>\n </div>\n <div className=\"overflow-hidden rounded-md border bg-background\">\n <Table>\n <TableHeader className=\"text-xs\">\n <TableRow className=\"bg-muted/50\">\n <TableHead className=\"h-9 py-2\">Name</TableHead>\n <TableHead className=\"h-9 py-2\">Type</TableHead>\n <TableHead className=\"h-9 py-2\">Size</TableHead>\n <TableHead className=\"h-9 w-0 py-2 text-right\">\n Actions\n </TableHead>\n </TableRow>\n </TableHeader>\n <TableBody className=\"text-[13px]\">\n {files.map((file) => (\n <TableRow key={file.id}>\n <TableCell className=\"max-w-48 py-2 font-medium\">\n <span className=\"flex items-center gap-2\">\n <span className=\"shrink-0\">{getFileIcon(file)}</span>{\" \"}\n <span className=\"truncate\">{file.file.name}</span>\n </span>\n </TableCell>\n <TableCell className=\"py-2 text-muted-foreground\">\n {file.file.type.split(\"/\")[1]?.toUpperCase() || \"UNKNOWN\"}\n </TableCell>\n <TableCell className=\"py-2 text-muted-foreground\">\n {formatBytes(file.file.size)}\n </TableCell>\n <TableCell className=\"py-2 text-right whitespace-nowrap\">\n <Button\n size=\"icon\"\n variant=\"ghost\"\n className=\"size-8 text-muted-foreground/80 hover:bg-transparent hover:text-foreground\"\n aria-label={`Download ${file.file.name}`}\n onClick={() => window.open(file.preview, \"_blank\")}\n >\n <DownloadIcon className=\"size-4\" />\n </Button>\n <Button\n size=\"icon\"\n variant=\"ghost\"\n className=\"size-8 text-muted-foreground/80 hover:bg-transparent hover:text-foreground\"\n aria-label={`Remove ${file.file.name}`}\n onClick={() => removeFile(file.id)}\n >\n <Trash2Icon className=\"size-4\" />\n </Button>\n </TableCell>\n </TableRow>\n ))}\n </TableBody>\n </Table>\n </div>\n </>\n )}\n\n {errors.length > 0 && (\n <div\n className=\"flex items-center gap-1 text-xs text-destructive\"\n role=\"alert\"\n >\n <AlertCircleIcon className=\"size-3 shrink-0\" />\n <span>{errors[0]}</span>\n </div>\n )}\n\n <p\n aria-live=\"polite\"\n role=\"region\"\n className=\"mt-2 text-center text-xs text-muted-foreground\"\n >\n Multiple files uploader w/ table ∙{\" \"}\n <a\n href=\"https://github.com/loveui/blob/main/apps/origin/docs/use-file-upload.md\"\n className=\"underline hover:text-foreground\"\n >\n API\n </a>\n </p>\n </div>\n )\n}\n",
|
|
13
|
+
"type": "registry:component"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"path": "registry/default/hooks/use-file-upload.ts",
|
|
17
|
+
"content": "\"use client\"\n\nimport type React from \"react\"\nimport {\n useCallback,\n useRef,\n useState,\n type ChangeEvent,\n type DragEvent,\n type InputHTMLAttributes,\n} from \"react\"\n\nexport type FileMetadata = {\n name: string\n size: number\n type: string\n url: string\n id: string\n}\n\nexport type FileWithPreview = {\n file: File | FileMetadata\n id: string\n preview?: string\n}\n\nexport type FileUploadOptions = {\n maxFiles?: number // Only used when multiple is true, defaults to Infinity\n maxSize?: number // in bytes\n accept?: string\n multiple?: boolean // Defaults to false\n initialFiles?: FileMetadata[]\n onFilesChange?: (files: FileWithPreview[]) => void // Callback when files change\n onFilesAdded?: (addedFiles: FileWithPreview[]) => void // Callback when new files are added\n}\n\nexport type FileUploadState = {\n files: FileWithPreview[]\n isDragging: boolean\n errors: string[]\n}\n\nexport type FileUploadActions = {\n addFiles: (files: FileList | File[]) => void\n removeFile: (id: string) => void\n clearFiles: () => void\n clearErrors: () => void\n handleDragEnter: (e: DragEvent<HTMLElement>) => void\n handleDragLeave: (e: DragEvent<HTMLElement>) => void\n handleDragOver: (e: DragEvent<HTMLElement>) => void\n handleDrop: (e: DragEvent<HTMLElement>) => void\n handleFileChange: (e: ChangeEvent<HTMLInputElement>) => void\n openFileDialog: () => void\n getInputProps: (\n props?: InputHTMLAttributes<HTMLInputElement>\n ) => InputHTMLAttributes<HTMLInputElement> & {\n // Use `any` here to avoid cross-React ref type conflicts across packages\n ref: any\n }\n}\n\nexport const useFileUpload = (\n options: FileUploadOptions = {}\n): [FileUploadState, FileUploadActions] => {\n const {\n maxFiles = Infinity,\n maxSize = Infinity,\n accept = \"*\",\n multiple = false,\n initialFiles = [],\n onFilesChange,\n onFilesAdded,\n } = options\n\n const [state, setState] = useState<FileUploadState>({\n files: initialFiles.map((file) => ({\n file,\n id: file.id,\n preview: file.url,\n })),\n isDragging: false,\n errors: [],\n })\n\n const inputRef = useRef<HTMLInputElement>(null)\n\n const validateFile = useCallback(\n (file: File | FileMetadata): string | null => {\n if (file instanceof File) {\n if (file.size > maxSize) {\n return `File \"${file.name}\" exceeds the maximum size of ${formatBytes(maxSize)}.`\n }\n } else {\n if (file.size > maxSize) {\n return `File \"${file.name}\" exceeds the maximum size of ${formatBytes(maxSize)}.`\n }\n }\n\n if (accept !== \"*\") {\n const acceptedTypes = accept.split(\",\").map((type) => type.trim())\n const fileType = file instanceof File ? file.type || \"\" : file.type\n const fileExtension = `.${file instanceof File ? file.name.split(\".\").pop() : file.name.split(\".\").pop()}`\n\n const isAccepted = acceptedTypes.some((type) => {\n if (type.startsWith(\".\")) {\n return fileExtension.toLowerCase() === type.toLowerCase()\n }\n if (type.endsWith(\"/*\")) {\n const baseType = type.split(\"/\")[0]\n return fileType.startsWith(`${baseType}/`)\n }\n return fileType === type\n })\n\n if (!isAccepted) {\n return `File \"${file instanceof File ? file.name : file.name}\" is not an accepted file type.`\n }\n }\n\n return null\n },\n [accept, maxSize]\n )\n\n const createPreview = useCallback(\n (file: File | FileMetadata): string | undefined => {\n if (file instanceof File) {\n return URL.createObjectURL(file)\n }\n return file.url\n },\n []\n )\n\n const generateUniqueId = useCallback((file: File | FileMetadata): string => {\n if (file instanceof File) {\n return `${file.name}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`\n }\n return file.id\n }, [])\n\n const clearFiles = useCallback(() => {\n setState((prev) => {\n // Clean up object URLs\n prev.files.forEach((file) => {\n if (\n file.preview &&\n file.file instanceof File &&\n file.file.type.startsWith(\"image/\")\n ) {\n URL.revokeObjectURL(file.preview)\n }\n })\n\n if (inputRef.current) {\n inputRef.current.value = \"\"\n }\n\n const newState = {\n ...prev,\n files: [],\n errors: [],\n }\n\n onFilesChange?.(newState.files)\n return newState\n })\n }, [onFilesChange])\n\n const addFiles = useCallback(\n (newFiles: FileList | File[]) => {\n if (!newFiles || newFiles.length === 0) return\n\n const newFilesArray = Array.from(newFiles)\n const errors: string[] = []\n\n // Clear existing errors when new files are uploaded\n setState((prev) => ({ ...prev, errors: [] }))\n\n // In single file mode, clear existing files first\n if (!multiple) {\n clearFiles()\n }\n\n // Check if adding these files would exceed maxFiles (only in multiple mode)\n if (\n multiple &&\n maxFiles !== Infinity &&\n state.files.length + newFilesArray.length > maxFiles\n ) {\n errors.push(`You can only upload a maximum of ${maxFiles} files.`)\n setState((prev) => ({ ...prev, errors }))\n return\n }\n\n const validFiles: FileWithPreview[] = []\n\n newFilesArray.forEach((file) => {\n // Only check for duplicates if multiple files are allowed\n if (multiple) {\n const isDuplicate = state.files.some(\n (existingFile) =>\n existingFile.file.name === file.name &&\n existingFile.file.size === file.size\n )\n\n // Skip duplicate files silently\n if (isDuplicate) {\n return\n }\n }\n\n // Check file size\n if (file.size > maxSize) {\n errors.push(\n multiple\n ? `Some files exceed the maximum size of ${formatBytes(maxSize)}.`\n : `File exceeds the maximum size of ${formatBytes(maxSize)}.`\n )\n return\n }\n\n const error = validateFile(file)\n if (error) {\n errors.push(error)\n } else {\n validFiles.push({\n file,\n id: generateUniqueId(file),\n preview: createPreview(file),\n })\n }\n })\n\n // Only update state if we have valid files to add\n if (validFiles.length > 0) {\n // Call the onFilesAdded callback with the newly added valid files\n onFilesAdded?.(validFiles)\n\n setState((prev) => {\n const newFiles = !multiple\n ? validFiles\n : [...prev.files, ...validFiles]\n onFilesChange?.(newFiles)\n return {\n ...prev,\n files: newFiles,\n errors,\n }\n })\n } else if (errors.length > 0) {\n setState((prev) => ({\n ...prev,\n errors,\n }))\n }\n\n // Reset input value after handling files\n if (inputRef.current) {\n inputRef.current.value = \"\"\n }\n },\n [\n state.files,\n maxFiles,\n multiple,\n maxSize,\n validateFile,\n createPreview,\n generateUniqueId,\n clearFiles,\n onFilesChange,\n onFilesAdded,\n ]\n )\n\n const removeFile = useCallback(\n (id: string) => {\n setState((prev) => {\n const fileToRemove = prev.files.find((file) => file.id === id)\n if (\n fileToRemove &&\n fileToRemove.preview &&\n fileToRemove.file instanceof File &&\n fileToRemove.file.type.startsWith(\"image/\")\n ) {\n URL.revokeObjectURL(fileToRemove.preview)\n }\n\n const newFiles = prev.files.filter((file) => file.id !== id)\n onFilesChange?.(newFiles)\n\n return {\n ...prev,\n files: newFiles,\n errors: [],\n }\n })\n },\n [onFilesChange]\n )\n\n const clearErrors = useCallback(() => {\n setState((prev) => ({\n ...prev,\n errors: [],\n }))\n }, [])\n\n const handleDragEnter = useCallback((e: DragEvent<HTMLElement>) => {\n e.preventDefault()\n e.stopPropagation()\n setState((prev) => ({ ...prev, isDragging: true }))\n }, [])\n\n const handleDragLeave = useCallback((e: DragEvent<HTMLElement>) => {\n e.preventDefault()\n e.stopPropagation()\n\n if (e.currentTarget.contains(e.relatedTarget as Node)) {\n return\n }\n\n setState((prev) => ({ ...prev, isDragging: false }))\n }, [])\n\n const handleDragOver = useCallback((e: DragEvent<HTMLElement>) => {\n e.preventDefault()\n e.stopPropagation()\n }, [])\n\n const handleDrop = useCallback(\n (e: DragEvent<HTMLElement>) => {\n e.preventDefault()\n e.stopPropagation()\n setState((prev) => ({ ...prev, isDragging: false }))\n\n // Don't process files if the input is disabled\n if (inputRef.current?.disabled) {\n return\n }\n\n if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {\n // In single file mode, only use the first file\n if (!multiple) {\n const file = e.dataTransfer.files[0]\n addFiles([file])\n } else {\n addFiles(e.dataTransfer.files)\n }\n }\n },\n [addFiles, multiple]\n )\n\n const handleFileChange = useCallback(\n (e: ChangeEvent<HTMLInputElement>) => {\n if (e.target.files && e.target.files.length > 0) {\n addFiles(e.target.files)\n }\n },\n [addFiles]\n )\n\n const openFileDialog = useCallback(() => {\n if (inputRef.current) {\n inputRef.current.click()\n }\n }, [])\n\n const getInputProps = useCallback(\n (props: InputHTMLAttributes<HTMLInputElement> = {}) => {\n return {\n ...props,\n type: \"file\" as const,\n onChange: handleFileChange,\n accept: props.accept || accept,\n multiple: props.multiple !== undefined ? props.multiple : multiple,\n // Cast to `any` to prevent mismatched React ref type errors across workspaces\n ref: inputRef as any,\n }\n },\n [accept, multiple, handleFileChange]\n )\n\n return [\n state,\n {\n addFiles,\n removeFile,\n clearFiles,\n clearErrors,\n handleDragEnter,\n handleDragLeave,\n handleDragOver,\n handleDrop,\n handleFileChange,\n openFileDialog,\n getInputProps,\n },\n ]\n}\n\n// Helper function to format bytes to human-readable format\nexport const formatBytes = (bytes: number, decimals = 2): string => {\n if (bytes === 0) return \"0 Bytes\"\n\n const k = 1024\n const dm = decimals < 0 ? 0 : decimals\n const sizes = [\"Bytes\", \"KB\", \"MB\", \"GB\", \"TB\", \"PB\", \"EB\", \"ZB\", \"YB\"]\n\n const i = Math.floor(Math.log(bytes) / Math.log(k))\n\n return Number.parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + sizes[i]\n}\n",
|
|
18
|
+
"type": "registry:hook"
|
|
19
|
+
}
|
|
20
|
+
],
|
|
21
|
+
"meta": {
|
|
22
|
+
"tags": [
|
|
23
|
+
"upload",
|
|
24
|
+
"file",
|
|
25
|
+
"image",
|
|
26
|
+
"drag and drop"
|
|
27
|
+
],
|
|
28
|
+
"colSpan": 2
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "comp-552",
|
|
4
|
+
"type": "registry:component",
|
|
5
|
+
"registryDependencies": [
|
|
6
|
+
"https://loveui.dev/building-blocks/r/button.json"
|
|
7
|
+
],
|
|
8
|
+
"files": [
|
|
9
|
+
{
|
|
10
|
+
"path": "registry/default/components/comp-552.tsx",
|
|
11
|
+
"content": "\"use client\"\n\nimport {\n AlertCircleIcon,\n FileArchiveIcon,\n FileIcon,\n FileSpreadsheetIcon,\n FileTextIcon,\n HeadphonesIcon,\n ImageIcon,\n Trash2Icon,\n UploadIcon,\n VideoIcon,\n XIcon,\n} from \"lucide-react\"\n\nimport {\n formatBytes,\n useFileUpload,\n} from \"@/registry/building-blocks/default/hooks/use-file-upload\"\nimport { Button } from \"@/registry/building-blocks/default/ui/button\"\n\n// Create some dummy initial files\nconst initialFiles = [\n {\n name: \"intro.zip\",\n size: 252873,\n type: \"application/zip\",\n url: \"https://example.com/intro.zip\",\n id: \"intro.zip-1744638436563-8u5xuls\",\n },\n {\n name: \"image-01.jpg\",\n size: 1528737,\n type: \"image/jpeg\",\n url: \"https://picsum.photos/1000/800?grayscale&random=1\",\n id: \"image-01-123456789\",\n },\n {\n name: \"audio.mp3\",\n size: 1528737,\n type: \"audio/mpeg\",\n url: \"https://example.com/audio.mp3\",\n id: \"audio-123456789\",\n },\n]\n\nconst getFileIcon = (file: { file: File | { type: string; name: string } }) => {\n const fileType = file.file instanceof File ? file.file.type : file.file.type\n const fileName = file.file instanceof File ? file.file.name : file.file.name\n\n const iconMap = {\n pdf: {\n icon: FileTextIcon,\n conditions: (type: string, name: string) =>\n type.includes(\"pdf\") ||\n name.endsWith(\".pdf\") ||\n type.includes(\"word\") ||\n name.endsWith(\".doc\") ||\n name.endsWith(\".docx\"),\n },\n archive: {\n icon: FileArchiveIcon,\n conditions: (type: string, name: string) =>\n type.includes(\"zip\") ||\n type.includes(\"archive\") ||\n name.endsWith(\".zip\") ||\n name.endsWith(\".rar\"),\n },\n excel: {\n icon: FileSpreadsheetIcon,\n conditions: (type: string, name: string) =>\n type.includes(\"excel\") ||\n name.endsWith(\".xls\") ||\n name.endsWith(\".xlsx\"),\n },\n video: {\n icon: VideoIcon,\n conditions: (type: string) => type.includes(\"video/\"),\n },\n audio: {\n icon: HeadphonesIcon,\n conditions: (type: string) => type.includes(\"audio/\"),\n },\n image: {\n icon: ImageIcon,\n conditions: (type: string) => type.startsWith(\"image/\"),\n },\n }\n\n for (const { icon: Icon, conditions } of Object.values(iconMap)) {\n if (conditions(fileType, fileName)) {\n return <Icon className=\"size-5 opacity-60\" />\n }\n }\n\n return <FileIcon className=\"size-5 opacity-60\" />\n}\n\nconst getFilePreview = (file: {\n file: File | { type: string; name: string; url?: string }\n}) => {\n const fileType = file.file instanceof File ? file.file.type : file.file.type\n const fileName = file.file instanceof File ? file.file.name : file.file.name\n\n const renderImage = (src: string) => (\n <img\n src={src}\n alt={fileName}\n className=\"size-full rounded-t-[inherit] object-cover\"\n />\n )\n\n return (\n <div className=\"flex aspect-square items-center justify-center overflow-hidden rounded-t-[inherit] bg-accent\">\n {fileType.startsWith(\"image/\") ? (\n file.file instanceof File ? (\n (() => {\n const previewUrl = URL.createObjectURL(file.file)\n return renderImage(previewUrl)\n })()\n ) : file.file.url ? (\n renderImage(file.file.url)\n ) : (\n <ImageIcon className=\"size-5 opacity-60\" />\n )\n ) : (\n getFileIcon(file)\n )}\n </div>\n )\n}\n\nexport default function Component() {\n const maxSizeMB = 5\n const maxSize = maxSizeMB * 1024 * 1024 // 5MB default\n const maxFiles = 6\n\n const [\n { files, isDragging, errors },\n {\n handleDragEnter,\n handleDragLeave,\n handleDragOver,\n handleDrop,\n openFileDialog,\n removeFile,\n clearFiles,\n getInputProps,\n },\n ] = useFileUpload({\n multiple: true,\n maxFiles,\n maxSize,\n initialFiles,\n })\n\n return (\n <div className=\"flex flex-col gap-2\">\n {/* Drop area */}\n <div\n onDragEnter={handleDragEnter}\n onDragLeave={handleDragLeave}\n onDragOver={handleDragOver}\n onDrop={handleDrop}\n data-dragging={isDragging || undefined}\n data-files={files.length > 0 || undefined}\n className=\"relative flex min-h-52 flex-col items-center overflow-hidden rounded-xl border border-dashed border-input p-4 transition-colors not-data-[files]:justify-center has-[input:focus]:border-ring has-[input:focus]:ring-[3px] has-[input:focus]:ring-ring/50 data-[dragging=true]:bg-accent/50\"\n >\n <input\n {...getInputProps()}\n className=\"sr-only\"\n aria-label=\"Upload image file\"\n />\n {files.length > 0 ? (\n <div className=\"flex w-full flex-col gap-3\">\n <div className=\"flex items-center justify-between gap-2\">\n <h3 className=\"truncate text-sm font-medium\">\n Files ({files.length})\n </h3>\n <div className=\"flex gap-2\">\n <Button variant=\"outline\" size=\"sm\" onClick={openFileDialog}>\n <UploadIcon\n className=\"-ms-0.5 size-3.5 opacity-60\"\n aria-hidden=\"true\"\n />\n Add files\n </Button>\n <Button variant=\"outline\" size=\"sm\" onClick={clearFiles}>\n <Trash2Icon\n className=\"-ms-0.5 size-3.5 opacity-60\"\n aria-hidden=\"true\"\n />\n Remove all\n </Button>\n </div>\n </div>\n\n <div className=\"grid grid-cols-2 gap-4 md:grid-cols-3\">\n {files.map((file) => (\n <div\n key={file.id}\n className=\"relative flex flex-col rounded-md border bg-background\"\n >\n {getFilePreview(file)}\n <Button\n onClick={() => removeFile(file.id)}\n size=\"icon\"\n className=\"absolute -top-2 -right-2 size-6 rounded-full border-2 border-background shadow-none focus-visible:border-background\"\n aria-label=\"Remove image\"\n >\n <XIcon className=\"size-3.5\" />\n </Button>\n <div className=\"flex min-w-0 flex-col gap-0.5 border-t p-3\">\n <p className=\"truncate text-[13px] font-medium\">\n {file.file.name}\n </p>\n <p className=\"truncate text-xs text-muted-foreground\">\n {formatBytes(file.file.size)}\n </p>\n </div>\n </div>\n ))}\n </div>\n </div>\n ) : (\n <div className=\"flex flex-col items-center justify-center px-4 py-3 text-center\">\n <div\n className=\"mb-2 flex size-11 shrink-0 items-center justify-center rounded-full border bg-background\"\n aria-hidden=\"true\"\n >\n <ImageIcon className=\"size-4 opacity-60\" />\n </div>\n <p className=\"mb-1.5 text-sm font-medium\">Drop your files here</p>\n <p className=\"text-xs text-muted-foreground\">\n Max {maxFiles} files ∙ Up to {maxSizeMB}MB\n </p>\n <Button variant=\"outline\" className=\"mt-4\" onClick={openFileDialog}>\n <UploadIcon className=\"-ms-1 opacity-60\" aria-hidden=\"true\" />\n Select images\n </Button>\n </div>\n )}\n </div>\n\n {errors.length > 0 && (\n <div\n className=\"flex items-center gap-1 text-xs text-destructive\"\n role=\"alert\"\n >\n <AlertCircleIcon className=\"size-3 shrink-0\" />\n <span>{errors[0]}</span>\n </div>\n )}\n\n <p\n aria-live=\"polite\"\n role=\"region\"\n className=\"mt-2 text-center text-xs text-muted-foreground\"\n >\n Mixed content w/ card ∙{\" \"}\n <a\n href=\"https://github.com/loveui/blob/main/apps/origin/docs/use-file-upload.md\"\n className=\"underline hover:text-foreground\"\n >\n API\n </a>\n </p>\n </div>\n )\n}\n",
|
|
12
|
+
"type": "registry:component"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"path": "registry/default/hooks/use-file-upload.ts",
|
|
16
|
+
"content": "\"use client\"\n\nimport type React from \"react\"\nimport {\n useCallback,\n useRef,\n useState,\n type ChangeEvent,\n type DragEvent,\n type InputHTMLAttributes,\n} from \"react\"\n\nexport type FileMetadata = {\n name: string\n size: number\n type: string\n url: string\n id: string\n}\n\nexport type FileWithPreview = {\n file: File | FileMetadata\n id: string\n preview?: string\n}\n\nexport type FileUploadOptions = {\n maxFiles?: number // Only used when multiple is true, defaults to Infinity\n maxSize?: number // in bytes\n accept?: string\n multiple?: boolean // Defaults to false\n initialFiles?: FileMetadata[]\n onFilesChange?: (files: FileWithPreview[]) => void // Callback when files change\n onFilesAdded?: (addedFiles: FileWithPreview[]) => void // Callback when new files are added\n}\n\nexport type FileUploadState = {\n files: FileWithPreview[]\n isDragging: boolean\n errors: string[]\n}\n\nexport type FileUploadActions = {\n addFiles: (files: FileList | File[]) => void\n removeFile: (id: string) => void\n clearFiles: () => void\n clearErrors: () => void\n handleDragEnter: (e: DragEvent<HTMLElement>) => void\n handleDragLeave: (e: DragEvent<HTMLElement>) => void\n handleDragOver: (e: DragEvent<HTMLElement>) => void\n handleDrop: (e: DragEvent<HTMLElement>) => void\n handleFileChange: (e: ChangeEvent<HTMLInputElement>) => void\n openFileDialog: () => void\n getInputProps: (\n props?: InputHTMLAttributes<HTMLInputElement>\n ) => InputHTMLAttributes<HTMLInputElement> & {\n // Use `any` here to avoid cross-React ref type conflicts across packages\n ref: any\n }\n}\n\nexport const useFileUpload = (\n options: FileUploadOptions = {}\n): [FileUploadState, FileUploadActions] => {\n const {\n maxFiles = Infinity,\n maxSize = Infinity,\n accept = \"*\",\n multiple = false,\n initialFiles = [],\n onFilesChange,\n onFilesAdded,\n } = options\n\n const [state, setState] = useState<FileUploadState>({\n files: initialFiles.map((file) => ({\n file,\n id: file.id,\n preview: file.url,\n })),\n isDragging: false,\n errors: [],\n })\n\n const inputRef = useRef<HTMLInputElement>(null)\n\n const validateFile = useCallback(\n (file: File | FileMetadata): string | null => {\n if (file instanceof File) {\n if (file.size > maxSize) {\n return `File \"${file.name}\" exceeds the maximum size of ${formatBytes(maxSize)}.`\n }\n } else {\n if (file.size > maxSize) {\n return `File \"${file.name}\" exceeds the maximum size of ${formatBytes(maxSize)}.`\n }\n }\n\n if (accept !== \"*\") {\n const acceptedTypes = accept.split(\",\").map((type) => type.trim())\n const fileType = file instanceof File ? file.type || \"\" : file.type\n const fileExtension = `.${file instanceof File ? file.name.split(\".\").pop() : file.name.split(\".\").pop()}`\n\n const isAccepted = acceptedTypes.some((type) => {\n if (type.startsWith(\".\")) {\n return fileExtension.toLowerCase() === type.toLowerCase()\n }\n if (type.endsWith(\"/*\")) {\n const baseType = type.split(\"/\")[0]\n return fileType.startsWith(`${baseType}/`)\n }\n return fileType === type\n })\n\n if (!isAccepted) {\n return `File \"${file instanceof File ? file.name : file.name}\" is not an accepted file type.`\n }\n }\n\n return null\n },\n [accept, maxSize]\n )\n\n const createPreview = useCallback(\n (file: File | FileMetadata): string | undefined => {\n if (file instanceof File) {\n return URL.createObjectURL(file)\n }\n return file.url\n },\n []\n )\n\n const generateUniqueId = useCallback((file: File | FileMetadata): string => {\n if (file instanceof File) {\n return `${file.name}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`\n }\n return file.id\n }, [])\n\n const clearFiles = useCallback(() => {\n setState((prev) => {\n // Clean up object URLs\n prev.files.forEach((file) => {\n if (\n file.preview &&\n file.file instanceof File &&\n file.file.type.startsWith(\"image/\")\n ) {\n URL.revokeObjectURL(file.preview)\n }\n })\n\n if (inputRef.current) {\n inputRef.current.value = \"\"\n }\n\n const newState = {\n ...prev,\n files: [],\n errors: [],\n }\n\n onFilesChange?.(newState.files)\n return newState\n })\n }, [onFilesChange])\n\n const addFiles = useCallback(\n (newFiles: FileList | File[]) => {\n if (!newFiles || newFiles.length === 0) return\n\n const newFilesArray = Array.from(newFiles)\n const errors: string[] = []\n\n // Clear existing errors when new files are uploaded\n setState((prev) => ({ ...prev, errors: [] }))\n\n // In single file mode, clear existing files first\n if (!multiple) {\n clearFiles()\n }\n\n // Check if adding these files would exceed maxFiles (only in multiple mode)\n if (\n multiple &&\n maxFiles !== Infinity &&\n state.files.length + newFilesArray.length > maxFiles\n ) {\n errors.push(`You can only upload a maximum of ${maxFiles} files.`)\n setState((prev) => ({ ...prev, errors }))\n return\n }\n\n const validFiles: FileWithPreview[] = []\n\n newFilesArray.forEach((file) => {\n // Only check for duplicates if multiple files are allowed\n if (multiple) {\n const isDuplicate = state.files.some(\n (existingFile) =>\n existingFile.file.name === file.name &&\n existingFile.file.size === file.size\n )\n\n // Skip duplicate files silently\n if (isDuplicate) {\n return\n }\n }\n\n // Check file size\n if (file.size > maxSize) {\n errors.push(\n multiple\n ? `Some files exceed the maximum size of ${formatBytes(maxSize)}.`\n : `File exceeds the maximum size of ${formatBytes(maxSize)}.`\n )\n return\n }\n\n const error = validateFile(file)\n if (error) {\n errors.push(error)\n } else {\n validFiles.push({\n file,\n id: generateUniqueId(file),\n preview: createPreview(file),\n })\n }\n })\n\n // Only update state if we have valid files to add\n if (validFiles.length > 0) {\n // Call the onFilesAdded callback with the newly added valid files\n onFilesAdded?.(validFiles)\n\n setState((prev) => {\n const newFiles = !multiple\n ? validFiles\n : [...prev.files, ...validFiles]\n onFilesChange?.(newFiles)\n return {\n ...prev,\n files: newFiles,\n errors,\n }\n })\n } else if (errors.length > 0) {\n setState((prev) => ({\n ...prev,\n errors,\n }))\n }\n\n // Reset input value after handling files\n if (inputRef.current) {\n inputRef.current.value = \"\"\n }\n },\n [\n state.files,\n maxFiles,\n multiple,\n maxSize,\n validateFile,\n createPreview,\n generateUniqueId,\n clearFiles,\n onFilesChange,\n onFilesAdded,\n ]\n )\n\n const removeFile = useCallback(\n (id: string) => {\n setState((prev) => {\n const fileToRemove = prev.files.find((file) => file.id === id)\n if (\n fileToRemove &&\n fileToRemove.preview &&\n fileToRemove.file instanceof File &&\n fileToRemove.file.type.startsWith(\"image/\")\n ) {\n URL.revokeObjectURL(fileToRemove.preview)\n }\n\n const newFiles = prev.files.filter((file) => file.id !== id)\n onFilesChange?.(newFiles)\n\n return {\n ...prev,\n files: newFiles,\n errors: [],\n }\n })\n },\n [onFilesChange]\n )\n\n const clearErrors = useCallback(() => {\n setState((prev) => ({\n ...prev,\n errors: [],\n }))\n }, [])\n\n const handleDragEnter = useCallback((e: DragEvent<HTMLElement>) => {\n e.preventDefault()\n e.stopPropagation()\n setState((prev) => ({ ...prev, isDragging: true }))\n }, [])\n\n const handleDragLeave = useCallback((e: DragEvent<HTMLElement>) => {\n e.preventDefault()\n e.stopPropagation()\n\n if (e.currentTarget.contains(e.relatedTarget as Node)) {\n return\n }\n\n setState((prev) => ({ ...prev, isDragging: false }))\n }, [])\n\n const handleDragOver = useCallback((e: DragEvent<HTMLElement>) => {\n e.preventDefault()\n e.stopPropagation()\n }, [])\n\n const handleDrop = useCallback(\n (e: DragEvent<HTMLElement>) => {\n e.preventDefault()\n e.stopPropagation()\n setState((prev) => ({ ...prev, isDragging: false }))\n\n // Don't process files if the input is disabled\n if (inputRef.current?.disabled) {\n return\n }\n\n if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {\n // In single file mode, only use the first file\n if (!multiple) {\n const file = e.dataTransfer.files[0]\n addFiles([file])\n } else {\n addFiles(e.dataTransfer.files)\n }\n }\n },\n [addFiles, multiple]\n )\n\n const handleFileChange = useCallback(\n (e: ChangeEvent<HTMLInputElement>) => {\n if (e.target.files && e.target.files.length > 0) {\n addFiles(e.target.files)\n }\n },\n [addFiles]\n )\n\n const openFileDialog = useCallback(() => {\n if (inputRef.current) {\n inputRef.current.click()\n }\n }, [])\n\n const getInputProps = useCallback(\n (props: InputHTMLAttributes<HTMLInputElement> = {}) => {\n return {\n ...props,\n type: \"file\" as const,\n onChange: handleFileChange,\n accept: props.accept || accept,\n multiple: props.multiple !== undefined ? props.multiple : multiple,\n // Cast to `any` to prevent mismatched React ref type errors across workspaces\n ref: inputRef as any,\n }\n },\n [accept, multiple, handleFileChange]\n )\n\n return [\n state,\n {\n addFiles,\n removeFile,\n clearFiles,\n clearErrors,\n handleDragEnter,\n handleDragLeave,\n handleDragOver,\n handleDrop,\n handleFileChange,\n openFileDialog,\n getInputProps,\n },\n ]\n}\n\n// Helper function to format bytes to human-readable format\nexport const formatBytes = (bytes: number, decimals = 2): string => {\n if (bytes === 0) return \"0 Bytes\"\n\n const k = 1024\n const dm = decimals < 0 ? 0 : decimals\n const sizes = [\"Bytes\", \"KB\", \"MB\", \"GB\", \"TB\", \"PB\", \"EB\", \"ZB\", \"YB\"]\n\n const i = Math.floor(Math.log(bytes) / Math.log(k))\n\n return Number.parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + sizes[i]\n}\n",
|
|
17
|
+
"type": "registry:hook"
|
|
18
|
+
}
|
|
19
|
+
],
|
|
20
|
+
"meta": {
|
|
21
|
+
"tags": [
|
|
22
|
+
"upload",
|
|
23
|
+
"file",
|
|
24
|
+
"image",
|
|
25
|
+
"drag and drop"
|
|
26
|
+
],
|
|
27
|
+
"colSpan": 2
|
|
28
|
+
}
|
|
29
|
+
}
|