view_primitives 0.1.0

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.
Files changed (140) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +84 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +198 -0
  5. data/lib/generators/view_primitives/add/add_generator.rb +110 -0
  6. data/lib/generators/view_primitives/add/templates/accordion/accordion_component.html.erb +10 -0
  7. data/lib/generators/view_primitives/add/templates/accordion/accordion_component.rb.tt +22 -0
  8. data/lib/generators/view_primitives/add/templates/accordion/accordion_controller.js +15 -0
  9. data/lib/generators/view_primitives/add/templates/accordion/accordion_item_component.rb.tt +47 -0
  10. data/lib/generators/view_primitives/add/templates/alert/alert_component.rb.tt +62 -0
  11. data/lib/generators/view_primitives/add/templates/alert_dialog/alert_dialog_component.rb.tt +55 -0
  12. data/lib/generators/view_primitives/add/templates/aspect_ratio/aspect_ratio_component.rb.tt +18 -0
  13. data/lib/generators/view_primitives/add/templates/audio/audio_component.rb.tt +51 -0
  14. data/lib/generators/view_primitives/add/templates/avatar/avatar_component.rb.tt +37 -0
  15. data/lib/generators/view_primitives/add/templates/badge/badge_component.rb.tt +35 -0
  16. data/lib/generators/view_primitives/add/templates/banner/banner_component.rb.tt +29 -0
  17. data/lib/generators/view_primitives/add/templates/bottom_nav/bottom_nav_component.rb.tt +38 -0
  18. data/lib/generators/view_primitives/add/templates/breadcrumb/breadcrumb_component.rb.tt +37 -0
  19. data/lib/generators/view_primitives/add/templates/button/button_component.rb.tt +61 -0
  20. data/lib/generators/view_primitives/add/templates/button_group/button_group_component.rb.tt +23 -0
  21. data/lib/generators/view_primitives/add/templates/calendar/calendar_component.rb.tt +121 -0
  22. data/lib/generators/view_primitives/add/templates/calendar/calendar_controller.js +86 -0
  23. data/lib/generators/view_primitives/add/templates/card/card_component.rb.tt +16 -0
  24. data/lib/generators/view_primitives/add/templates/card/card_content_component.rb.tt +16 -0
  25. data/lib/generators/view_primitives/add/templates/card/card_description_component.rb.tt +17 -0
  26. data/lib/generators/view_primitives/add/templates/card/card_footer_component.rb.tt +16 -0
  27. data/lib/generators/view_primitives/add/templates/card/card_header_component.rb.tt +17 -0
  28. data/lib/generators/view_primitives/add/templates/card/card_title_component.rb.tt +17 -0
  29. data/lib/generators/view_primitives/add/templates/carousel/carousel_component.rb.tt +102 -0
  30. data/lib/generators/view_primitives/add/templates/carousel/carousel_controller.js +48 -0
  31. data/lib/generators/view_primitives/add/templates/chart/chart_component.rb.tt +63 -0
  32. data/lib/generators/view_primitives/add/templates/chart/chart_controller.js +29 -0
  33. data/lib/generators/view_primitives/add/templates/chat_bubble/chat_bubble_component.rb.tt +53 -0
  34. data/lib/generators/view_primitives/add/templates/checkbox/checkbox_component.rb.tt +50 -0
  35. data/lib/generators/view_primitives/add/templates/collapsible/collapsible_component.rb.tt +31 -0
  36. data/lib/generators/view_primitives/add/templates/combobox/combobox_component.rb.tt +87 -0
  37. data/lib/generators/view_primitives/add/templates/combobox/combobox_controller.js +38 -0
  38. data/lib/generators/view_primitives/add/templates/command/command_component.rb.tt +85 -0
  39. data/lib/generators/view_primitives/add/templates/command/command_controller.js +50 -0
  40. data/lib/generators/view_primitives/add/templates/context_menu/context_menu_component.rb.tt +47 -0
  41. data/lib/generators/view_primitives/add/templates/context_menu/context_menu_controller.js +20 -0
  42. data/lib/generators/view_primitives/add/templates/data_table/data_table_component.rb.tt +163 -0
  43. data/lib/generators/view_primitives/add/templates/data_table/data_table_controller.js +115 -0
  44. data/lib/generators/view_primitives/add/templates/date_picker/date_picker_component.rb.tt +92 -0
  45. data/lib/generators/view_primitives/add/templates/date_picker/date_picker_controller.js +48 -0
  46. data/lib/generators/view_primitives/add/templates/device_mockup/device_mockup_component.rb.tt +65 -0
  47. data/lib/generators/view_primitives/add/templates/dialog/dialog_component.rb.tt +71 -0
  48. data/lib/generators/view_primitives/add/templates/dialog/dialog_controller.js +15 -0
  49. data/lib/generators/view_primitives/add/templates/drawer/drawer_component.rb.tt +62 -0
  50. data/lib/generators/view_primitives/add/templates/drawer/drawer_controller.js +15 -0
  51. data/lib/generators/view_primitives/add/templates/dropdown_menu/dropdown_controller.js +17 -0
  52. data/lib/generators/view_primitives/add/templates/dropdown_menu/dropdown_menu_component.rb.tt +53 -0
  53. data/lib/generators/view_primitives/add/templates/embed/embed_component.rb.tt +271 -0
  54. data/lib/generators/view_primitives/add/templates/embed/embed_controller.js +43 -0
  55. data/lib/generators/view_primitives/add/templates/figure/figure_component.rb.tt +24 -0
  56. data/lib/generators/view_primitives/add/templates/file_input/file_input_component.rb.tt +31 -0
  57. data/lib/generators/view_primitives/add/templates/floating_label/floating_label_component.rb.tt +54 -0
  58. data/lib/generators/view_primitives/add/templates/footer/footer_component.rb.tt +51 -0
  59. data/lib/generators/view_primitives/add/templates/form_field/form_field_component.rb.tt +51 -0
  60. data/lib/generators/view_primitives/add/templates/gallery/gallery_component.rb.tt +83 -0
  61. data/lib/generators/view_primitives/add/templates/gallery/gallery_controller.js +28 -0
  62. data/lib/generators/view_primitives/add/templates/hover_card/hover_card_component.rb.tt +43 -0
  63. data/lib/generators/view_primitives/add/templates/iframe/iframe_component.rb.tt +59 -0
  64. data/lib/generators/view_primitives/add/templates/image/image_component.rb.tt +38 -0
  65. data/lib/generators/view_primitives/add/templates/indicator/indicator_component.rb.tt +46 -0
  66. data/lib/generators/view_primitives/add/templates/input/input_component.rb.tt +28 -0
  67. data/lib/generators/view_primitives/add/templates/input_otp/input_otp_component.rb.tt +65 -0
  68. data/lib/generators/view_primitives/add/templates/input_otp/input_otp_controller.js +39 -0
  69. data/lib/generators/view_primitives/add/templates/kbd/kbd_component.rb.tt +21 -0
  70. data/lib/generators/view_primitives/add/templates/label/label_component.rb.tt +23 -0
  71. data/lib/generators/view_primitives/add/templates/list_group/list_group_component.rb.tt +16 -0
  72. data/lib/generators/view_primitives/add/templates/list_group/list_group_item_component.rb.tt +31 -0
  73. data/lib/generators/view_primitives/add/templates/map_area/map_area_component.rb.tt +72 -0
  74. data/lib/generators/view_primitives/add/templates/mega_menu/mega_menu_component.rb.tt +130 -0
  75. data/lib/generators/view_primitives/add/templates/mega_menu/mega_menu_controller.js +23 -0
  76. data/lib/generators/view_primitives/add/templates/menubar/menubar_component.rb.tt +33 -0
  77. data/lib/generators/view_primitives/add/templates/menubar/menubar_controller.js +34 -0
  78. data/lib/generators/view_primitives/add/templates/menubar/menubar_menu_component.rb.tt +34 -0
  79. data/lib/generators/view_primitives/add/templates/navbar/navbar_component.rb.tt +90 -0
  80. data/lib/generators/view_primitives/add/templates/navbar/navbar_controller.js +11 -0
  81. data/lib/generators/view_primitives/add/templates/navigation_menu/navigation_menu_component.rb.tt +132 -0
  82. data/lib/generators/view_primitives/add/templates/navigation_menu/navigation_menu_controller.js +25 -0
  83. data/lib/generators/view_primitives/add/templates/number_input/number_input_component.rb.tt +34 -0
  84. data/lib/generators/view_primitives/add/templates/pagination/pagination_component.rb.tt +97 -0
  85. data/lib/generators/view_primitives/add/templates/picture/picture_component.rb.tt +63 -0
  86. data/lib/generators/view_primitives/add/templates/popover/popover_component.rb.tt +56 -0
  87. data/lib/generators/view_primitives/add/templates/popover/popover_controller.js +17 -0
  88. data/lib/generators/view_primitives/add/templates/progress/progress_component.rb.tt +28 -0
  89. data/lib/generators/view_primitives/add/templates/qr_code/qr_code_component.rb.tt +39 -0
  90. data/lib/generators/view_primitives/add/templates/radio_group/radio_group_component.rb.tt +51 -0
  91. data/lib/generators/view_primitives/add/templates/range/range_component.rb.tt +40 -0
  92. data/lib/generators/view_primitives/add/templates/rating/rating_component.rb.tt +42 -0
  93. data/lib/generators/view_primitives/add/templates/rating_input/rating_controller.js +47 -0
  94. data/lib/generators/view_primitives/add/templates/rating_input/rating_input_component.rb.tt +79 -0
  95. data/lib/generators/view_primitives/add/templates/resizable/resizable_component.rb.tt +91 -0
  96. data/lib/generators/view_primitives/add/templates/resizable/resizable_controller.js +38 -0
  97. data/lib/generators/view_primitives/add/templates/scroll_area/scroll_area_component.rb.tt +41 -0
  98. data/lib/generators/view_primitives/add/templates/search_input/search_input_component.rb.tt +50 -0
  99. data/lib/generators/view_primitives/add/templates/select/select_component.rb.tt +45 -0
  100. data/lib/generators/view_primitives/add/templates/separator/separator_component.rb.tt +25 -0
  101. data/lib/generators/view_primitives/add/templates/sheet/sheet_component.rb.tt +78 -0
  102. data/lib/generators/view_primitives/add/templates/sheet/sheet_controller.js +15 -0
  103. data/lib/generators/view_primitives/add/templates/sidebar/sidebar_component.rb.tt +169 -0
  104. data/lib/generators/view_primitives/add/templates/sidebar/sidebar_controller.js +11 -0
  105. data/lib/generators/view_primitives/add/templates/skeleton/skeleton_component.rb.tt +16 -0
  106. data/lib/generators/view_primitives/add/templates/speed_dial/speed_dial_component.rb.tt +111 -0
  107. data/lib/generators/view_primitives/add/templates/speed_dial/speed_dial_controller.js +22 -0
  108. data/lib/generators/view_primitives/add/templates/spinner/spinner_component.rb.tt +27 -0
  109. data/lib/generators/view_primitives/add/templates/stepper/stepper_component.rb.tt +99 -0
  110. data/lib/generators/view_primitives/add/templates/switch/switch_component.rb.tt +51 -0
  111. data/lib/generators/view_primitives/add/templates/tabs/tabs_component.html.erb +50 -0
  112. data/lib/generators/view_primitives/add/templates/tabs/tabs_component.rb.tt +16 -0
  113. data/lib/generators/view_primitives/add/templates/tabs/tabs_controller.js +26 -0
  114. data/lib/generators/view_primitives/add/templates/tabs/tabs_item_component.rb.tt +15 -0
  115. data/lib/generators/view_primitives/add/templates/textarea/textarea_component.rb.tt +24 -0
  116. data/lib/generators/view_primitives/add/templates/timeline/timeline_component.rb.tt +78 -0
  117. data/lib/generators/view_primitives/add/templates/timepicker/timepicker_component.rb.tt +140 -0
  118. data/lib/generators/view_primitives/add/templates/timepicker/timepicker_controller.js +92 -0
  119. data/lib/generators/view_primitives/add/templates/toaster/toaster_component.rb.tt +152 -0
  120. data/lib/generators/view_primitives/add/templates/toaster/toaster_controller.js +88 -0
  121. data/lib/generators/view_primitives/add/templates/toggle/toggle_component.rb.tt +41 -0
  122. data/lib/generators/view_primitives/add/templates/toggle/toggle_controller.js +12 -0
  123. data/lib/generators/view_primitives/add/templates/toggle_group/toggle_group_component.rb.tt +32 -0
  124. data/lib/generators/view_primitives/add/templates/toggle_group/toggle_group_controller.js +38 -0
  125. data/lib/generators/view_primitives/add/templates/tooltip/tooltip_component.rb.tt +42 -0
  126. data/lib/generators/view_primitives/add/templates/video/video_component.rb.tt +92 -0
  127. data/lib/generators/view_primitives/add/templates/wysiwyg/wysiwyg_component.rb.tt +88 -0
  128. data/lib/generators/view_primitives/add/templates/wysiwyg/wysiwyg_controller.js +40 -0
  129. data/lib/generators/view_primitives/components.rb +62 -0
  130. data/lib/generators/view_primitives/detector.rb +43 -0
  131. data/lib/generators/view_primitives/install/install_generator.rb +65 -0
  132. data/lib/generators/view_primitives/install/templates/application_component.rb.tt +5 -0
  133. data/lib/generators/view_primitives/install/templates/view_primitives.css +67 -0
  134. data/lib/generators/view_primitives/list/list_generator.rb +25 -0
  135. data/lib/view_primitives/class_helper.rb +11 -0
  136. data/lib/view_primitives/component_helper.rb +20 -0
  137. data/lib/view_primitives/railtie.rb +21 -0
  138. data/lib/view_primitives/version.rb +5 -0
  139. data/lib/view_primitives.rb +12 -0
  140. metadata +267 -0
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class DropdownMenuComponent < ApplicationComponent
5
+ renders_one :trigger
6
+
7
+ PANEL = "absolute z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 " \
8
+ "text-popover-foreground shadow-md"
9
+
10
+ ALIGN = {
11
+ start: "top-full left-0 mt-1",
12
+ end: "top-full right-0 mt-1",
13
+ }.freeze
14
+
15
+ ITEM = "relative flex w-full cursor-default select-none items-center gap-2 rounded-sm " \
16
+ "px-2 py-1.5 text-sm outline-none " \
17
+ "hover:bg-accent hover:text-accent-foreground " \
18
+ "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 " \
19
+ "[&_svg:not([class*='text-'])]:text-muted-foreground"
20
+ SEPARATOR = "-mx-1 my-1 h-px bg-border"
21
+ LABEL_CLS = "px-2 py-1.5 text-sm font-medium"
22
+
23
+ def initialize(align: :start, **html_attrs)
24
+ @align = align.to_sym
25
+ @extra_class = html_attrs.delete(:class)
26
+ @html_attrs = html_attrs
27
+ end
28
+
29
+ def call
30
+ content_tag(:div,
31
+ class: "relative inline-block",
32
+ data: {
33
+ controller: "dropdown",
34
+ action: "click@document->dropdown#closeOnClickOutside"
35
+ },
36
+ **@html_attrs) do
37
+ concat content_tag(:span, trigger, data: { action: "click->dropdown#toggle" }, class: "contents") if trigger
38
+ concat panel
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def panel
45
+ content_tag(:div,
46
+ data: { dropdown_target: "panel" },
47
+ hidden: true,
48
+ class: cn(PANEL, ALIGN.fetch(@align, ALIGN[:start]), @extra_class)) do
49
+ concat content
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,271 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class EmbedComponent < ApplicationComponent
5
+ # Embeds third-party content. Pass url: — the provider is detected
6
+ # automatically from the domain. For Google Maps you may also use query:.
7
+ #
8
+ # Usage:
9
+ # ui :embed, url: "https://youtu.be/dQw4w9WgXcQ"
10
+ # ui :embed, url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
11
+ # ui :embed, url: "https://vimeo.com/148751763"
12
+ # ui :embed, url: "https://open.spotify.com/track/4iV5W9uYEdYUVa79Axb7Rh"
13
+ # ui :embed, url: "https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M"
14
+ # ui :embed, url: "https://www.loom.com/share/abc123def456"
15
+ # ui :embed, url: "https://soundcloud.com/artist/track"
16
+ # ui :embed, url: "https://x.com/jack/status/20"
17
+ # ui :embed, url: "https://t.me/telegram/193"
18
+ # ui :embed, url: "https://www.facebook.com/watch/?v=123456"
19
+ # ui :embed, url: "https://www.google.com/maps/place/Eiffel+Tower"
20
+ # ui :embed, url: "https://yandex.ru/maps/213/moscow/?ll=37.617685,55.755814&z=10"
21
+ # ui :embed, query: "Eiffel Tower, Paris" # Google Maps — search query
22
+
23
+ PROVIDERS = {
24
+ youtube: { aspect: "16/9", sandbox: "allow-scripts allow-same-origin allow-presentation allow-popups" },
25
+ vimeo: { aspect: "16/9", sandbox: "allow-scripts allow-same-origin allow-presentation allow-popups" },
26
+ spotify: { aspect: nil, sandbox: "allow-scripts allow-same-origin allow-popups" },
27
+ google_maps: { aspect: "16/9", sandbox: "allow-scripts allow-same-origin" },
28
+ yandex_maps: { aspect: "16/9", sandbox: "allow-scripts allow-same-origin" },
29
+ loom: { aspect: "16/9", sandbox: "allow-scripts allow-same-origin allow-presentation allow-popups" },
30
+ soundcloud: { aspect: nil, sandbox: "allow-scripts allow-same-origin allow-popups" },
31
+ x: { aspect: nil, sandbox: nil },
32
+ telegram: { aspect: nil, sandbox: nil },
33
+ facebook: { aspect: "16/9", sandbox: "allow-scripts allow-same-origin allow-popups allow-forms" }
34
+ }.freeze
35
+
36
+ WIDGET_PROVIDERS = %i[x telegram].freeze
37
+
38
+ DOMAIN_MAP = {
39
+ /youtube\.com|youtu\.be/i => :youtube,
40
+ /vimeo\.com/i => :vimeo,
41
+ /open\.spotify\.com/i => :spotify,
42
+ /loom\.com/i => :loom,
43
+ /soundcloud\.com/i => :soundcloud,
44
+ /(?:twitter|x)\.com/i => :x,
45
+ /t\.me|telegram\.org/i => :telegram,
46
+ /facebook\.com|fb\.com/i => :facebook,
47
+ /maps\.google|google\.com\/maps|maps\.app\.goo\.gl/i => :google_maps,
48
+ /yandex\.(ru|com)\/maps/i => :yandex_maps
49
+ }.freeze
50
+
51
+ WRAPPER_CLS = "overflow-hidden rounded-md"
52
+ DARK_WRAPPER_CLS = "overflow-hidden rounded-md bg-black"
53
+
54
+ def self.detect_provider(url)
55
+ DOMAIN_MAP.each { |pattern, provider| return provider if url.to_s.match?(pattern) }
56
+ nil
57
+ end
58
+
59
+ def initialize(url: nil, query: nil, aspect: nil, height: nil, title: nil, **html_attrs)
60
+ @type = query ? :google_maps : self.class.detect_provider(url)
61
+ @url = url
62
+ @query = query
63
+ @aspect = aspect || PROVIDERS.dig(@type, :aspect)
64
+ @height = height || default_height
65
+ @title = title || default_title
66
+ @extra_class = html_attrs.delete(:class)
67
+ @html_attrs = html_attrs
68
+ end
69
+
70
+ def call
71
+ return unsupported_msg unless @type && PROVIDERS.key?(@type)
72
+
73
+ if WIDGET_PROVIDERS.include?(@type)
74
+ widget_markup
75
+ else
76
+ iframe_markup
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ # ── Widget-based providers (X, Telegram) ─────────────────────────────────
83
+
84
+ def widget_markup
85
+ case @type
86
+ when :x then x_widget
87
+ when :telegram then telegram_widget
88
+ end
89
+ end
90
+
91
+ def x_widget
92
+ tweet_id = extract_tweet_id(@url.to_s)
93
+ return unsupported_msg unless tweet_id
94
+
95
+ content_tag(:div,
96
+ class: cn(WRAPPER_CLS, @extra_class),
97
+ data: { controller: "embed", embed_provider_value: "x", embed_post_id_value: tweet_id },
98
+ **@html_attrs) do
99
+ content_tag(:blockquote, class: "twitter-tweet", "data-dnt": "true") do
100
+ tag.a(href: "https://twitter.com/i/status/#{tweet_id}")
101
+ end
102
+ end
103
+ end
104
+
105
+ def telegram_widget
106
+ return unsupported_msg unless @url
107
+
108
+ post_id = @url.sub(%r{\Ahttps://t\.me/}, "").sub(%r{\A@}, "")
109
+
110
+ content_tag(:div, "",
111
+ class: cn(WRAPPER_CLS, @extra_class),
112
+ data: { controller: "embed", embed_provider_value: "telegram", embed_post_id_value: post_id },
113
+ **@html_attrs)
114
+ end
115
+
116
+ # ── Iframe-based providers ────────────────────────────────────────────────
117
+
118
+ def iframe_markup
119
+ embed_url = build_embed_url
120
+ return unsupported_msg unless embed_url
121
+
122
+ sandbox = PROVIDERS.dig(@type, :sandbox)
123
+ iframe_attrs = {
124
+ src: embed_url, title: @title, loading: "lazy",
125
+ class: "w-full h-full border-0 block",
126
+ allowfullscreen: true,
127
+ allow: "autoplay; fullscreen; picture-in-picture"
128
+ }
129
+ iframe_attrs[:sandbox] = sandbox if sandbox
130
+
131
+ wrapper_style = @aspect ? "aspect-ratio: #{@aspect}" : "height: #{@height}px"
132
+ content_tag(:div,
133
+ class: cn(DARK_WRAPPER_CLS, @extra_class),
134
+ style: wrapper_style,
135
+ **@html_attrs) do
136
+ tag.iframe(**iframe_attrs)
137
+ end
138
+ end
139
+
140
+ def build_embed_url
141
+ case @type
142
+ when :youtube then youtube_url
143
+ when :vimeo then vimeo_url
144
+ when :spotify then spotify_url
145
+ when :google_maps then google_maps_url
146
+ when :yandex_maps then yandex_maps_url
147
+ when :loom then loom_url
148
+ when :soundcloud then soundcloud_url
149
+ when :facebook then facebook_url
150
+ end
151
+ end
152
+
153
+ def unsupported_msg
154
+ content_tag(:p, "Unsupported embed type: #{@type}", class: "text-sm text-destructive")
155
+ end
156
+
157
+ # ── Embed URL builders ────────────────────────────────────────────────────
158
+
159
+ def youtube_url
160
+ vid = extract_youtube_id(@url.to_s)
161
+ "https://www.youtube.com/embed/#{vid}?rel=0" if vid
162
+ end
163
+
164
+ def vimeo_url
165
+ vid = extract_vimeo_id(@url.to_s)
166
+ "https://player.vimeo.com/video/#{vid}?dnt=1" if vid
167
+ end
168
+
169
+ def spotify_url
170
+ path = extract_spotify_path(@url.to_s)
171
+ "https://open.spotify.com/embed/#{path}" if path
172
+ end
173
+
174
+ def google_maps_url
175
+ if @query
176
+ "https://maps.google.com/maps?q=#{CGI.escape(@query)}&output=embed"
177
+ elsif @url
178
+ return @url if @url.include?("output=embed") || @url.match?(%r{/maps/embed})
179
+ uri = URI.parse(@url)
180
+ q = CGI.parse(uri.query.to_s)["q"]&.first
181
+ if q
182
+ "https://maps.google.com/maps?q=#{CGI.escape(q)}&output=embed"
183
+ else
184
+ sep = @url.include?("?") ? "&" : "?"
185
+ "#{@url}#{sep}output=embed"
186
+ end
187
+ end
188
+ rescue URI::InvalidURIError
189
+ nil
190
+ end
191
+
192
+ def yandex_maps_url
193
+ return nil unless @url
194
+ @url.sub(%r{yandex\.(ru|com)/maps}, 'yandex.\1/map-widget/v1')
195
+ end
196
+
197
+ def loom_url
198
+ vid = extract_loom_id(@url.to_s)
199
+ "https://www.loom.com/embed/#{vid}" if vid
200
+ end
201
+
202
+ def soundcloud_url
203
+ return nil unless @url
204
+ "https://w.soundcloud.com/player/?url=#{CGI.escape(@url)}&auto_play=false&hide_related=true&show_comments=false&visual=false"
205
+ end
206
+
207
+ def facebook_url
208
+ return nil unless @url
209
+ "https://www.facebook.com/plugins/video.php?href=#{CGI.escape(@url)}&show_text=false"
210
+ end
211
+
212
+ # ── ID extractors ─────────────────────────────────────────────────────────
213
+
214
+ def extract_youtube_id(url)
215
+ uri = URI.parse(url)
216
+ if uri.host&.include?("youtu.be")
217
+ uri.path.delete_prefix("/")
218
+ elsif uri.host&.include?("youtube.com")
219
+ CGI.parse(uri.query.to_s)["v"]&.first
220
+ end
221
+ rescue URI::InvalidURIError
222
+ nil
223
+ end
224
+
225
+ def extract_vimeo_id(url)
226
+ URI.parse(url).path.split("/").last
227
+ rescue URI::InvalidURIError
228
+ nil
229
+ end
230
+
231
+ def extract_spotify_path(url)
232
+ m = url.match(%r{open\.spotify\.com/(track|album|playlist|episode|show)/([^?]+)})
233
+ m ? "#{m[1]}/#{m[2]}" : nil
234
+ end
235
+
236
+ def extract_loom_id(url)
237
+ URI.parse(url).path.split("/").last
238
+ rescue URI::InvalidURIError
239
+ nil
240
+ end
241
+
242
+ def extract_tweet_id(url)
243
+ URI.parse(url).path.split("/").last
244
+ rescue URI::InvalidURIError
245
+ nil
246
+ end
247
+
248
+ def default_height
249
+ case @type
250
+ when :spotify then 152
251
+ when :soundcloud then 166
252
+ else 400
253
+ end
254
+ end
255
+
256
+ def default_title
257
+ {
258
+ youtube: "YouTube video",
259
+ vimeo: "Vimeo video",
260
+ spotify: "Spotify player",
261
+ google_maps: "Google Maps",
262
+ yandex_maps: "Yandex Maps",
263
+ loom: "Loom video",
264
+ soundcloud: "SoundCloud player",
265
+ x: "Post on X",
266
+ telegram: "Telegram post",
267
+ facebook: "Facebook video"
268
+ }.fetch(@type, "Embedded content")
269
+ end
270
+ end
271
+ end
@@ -0,0 +1,43 @@
1
+ // Official widget initializer for X (Twitter) and Telegram embeds.
2
+ // Called on every Stimulus connect so widgets survive Turbo navigations.
3
+ import { Controller } from "@hotwired/stimulus"
4
+
5
+ export default class extends Controller {
6
+ static values = {
7
+ provider: String,
8
+ postId: String
9
+ }
10
+
11
+ connect() {
12
+ if (this.providerValue === "x") this.#initX()
13
+ if (this.providerValue === "telegram") this.#initTelegram()
14
+ }
15
+
16
+ async #initX() {
17
+ if (!window.twttr) {
18
+ await this.#loadScript("https://platform.twitter.com/widgets.js")
19
+ }
20
+ window.twttr?.widgets?.load(this.element)
21
+ }
22
+
23
+ #initTelegram() {
24
+ // Remove any previously injected script (Turbo re-renders)
25
+ this.element.querySelectorAll("script[data-telegram-post]").forEach((s) => s.remove())
26
+
27
+ const script = document.createElement("script")
28
+ script.setAttribute("async", "")
29
+ script.src = "https://telegram.org/js/telegram-widget.js?22"
30
+ script.dataset.telegramPost = this.postIdValue
31
+ script.dataset.width = "100%"
32
+ this.element.appendChild(script)
33
+ }
34
+
35
+ #loadScript(src) {
36
+ return new Promise((resolve) => {
37
+ if (document.querySelector(`script[src="${src}"]`)) { resolve(); return }
38
+ const s = Object.assign(document.createElement("script"), { src, async: true })
39
+ s.onload = resolve
40
+ document.head.appendChild(s)
41
+ })
42
+ }
43
+ }
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class FigureComponent < ApplicationComponent
5
+ CAPTION = "mt-2 text-sm text-muted-foreground"
6
+
7
+ # caption: text shown in <figcaption> (optional; omit to render none)
8
+ # caption_class: override the figcaption classes
9
+ def initialize(caption: nil, caption_class: nil, **html_attrs)
10
+ @caption = caption
11
+ @caption_class = caption_class
12
+ @extra_class = html_attrs.delete(:class)
13
+ @html_attrs = html_attrs
14
+ end
15
+
16
+ def call
17
+ content_tag(:figure, class: @extra_class, **@html_attrs) do
18
+ concat content
19
+ concat content_tag(:figcaption, @caption,
20
+ class: cn(CAPTION, @caption_class)) if @caption
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class FileInputComponent < ApplicationComponent
5
+ BASE = "h-9 w-full min-w-0 cursor-pointer rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs " \
6
+ "transition-[color,box-shadow] outline-none " \
7
+ "file:mr-3 file:inline-flex file:h-7 file:cursor-pointer file:border-0 " \
8
+ "file:bg-transparent file:text-sm file:font-medium file:text-foreground " \
9
+ "placeholder:text-muted-foreground " \
10
+ "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 " \
11
+ "aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 " \
12
+ "disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 " \
13
+ "md:text-sm dark:bg-input/30"
14
+
15
+ # accept: MIME types or extensions, e.g. "image/*" or ".pdf,.docx"
16
+ # multiple: allow selecting multiple files
17
+ def initialize(accept: nil, multiple: false, **html_attrs)
18
+ @accept = accept
19
+ @multiple = multiple
20
+ @extra_class = html_attrs.delete(:class)
21
+ @html_attrs = html_attrs
22
+ end
23
+
24
+ def call
25
+ attrs = { type: "file", class: cn(BASE, @extra_class) }
26
+ attrs[:accept] = @accept if @accept
27
+ attrs[:multiple] = true if @multiple
28
+ content_tag(:input, nil, **attrs, **@html_attrs)
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class FloatingLabelComponent < ApplicationComponent
5
+ WRAPPER = "relative w-full"
6
+
7
+ # The label floats via CSS peer — it sits inside the input border initially,
8
+ # then rises above when the input is focused or has a value (:not(:placeholder-shown)).
9
+ INPUT_BASE = "peer h-12 w-full min-w-0 rounded-md border border-input bg-transparent px-3 pb-1.5 pt-4 " \
10
+ "text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-transparent " \
11
+ "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 " \
12
+ "aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 " \
13
+ "disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 " \
14
+ "md:text-sm dark:bg-input/30"
15
+
16
+ LABEL_BASE = "pointer-events-none absolute left-3 top-3 origin-[0_0] text-sm text-muted-foreground " \
17
+ "transition-all duration-200 " \
18
+ "peer-focus:-translate-y-2 peer-focus:scale-75 peer-focus:text-foreground " \
19
+ "peer-[:not(:placeholder-shown)]:-translate-y-2 peer-[:not(:placeholder-shown)]:scale-75"
20
+
21
+ # label: visible label text (required)
22
+ # type: input type, default "text"
23
+ # id: ties label[for] to input; auto-generated from name if omitted
24
+ def initialize(label:, type: "text", id: nil, **html_attrs)
25
+ @label = label
26
+ @type = type
27
+ name = html_attrs[:name]
28
+ @id = id || html_attrs[:id] || (name ? name.to_s.gsub(/[\[\]]+/, "_") : nil)
29
+ @extra_class = html_attrs.delete(:class)
30
+ @html_attrs = html_attrs
31
+ end
32
+
33
+ def call
34
+ content_tag(:div, class: WRAPPER) do
35
+ concat input_tag
36
+ concat label_tag
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def input_tag
43
+ attrs = { type: @type, placeholder: @label, class: cn(INPUT_BASE, @extra_class) }
44
+ attrs[:id] = @id if @id
45
+ content_tag(:input, nil, **attrs, **@html_attrs)
46
+ end
47
+
48
+ def label_tag
49
+ attrs = { class: LABEL_BASE }
50
+ attrs[:for] = @id if @id
51
+ content_tag(:label, @label, **attrs)
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class FooterComponent < ApplicationComponent
5
+ BASE = "border-t bg-background"
6
+ LINK = "text-sm text-muted-foreground hover:text-foreground transition-colors"
7
+
8
+ # columns: [{ title:, links: [{ label:, href: }] }]
9
+ def initialize(copyright: nil, columns: [], **html_attrs)
10
+ @copyright = copyright
11
+ @columns = columns
12
+ @extra_class = html_attrs.delete(:class)
13
+ @html_attrs = html_attrs
14
+ end
15
+
16
+ def call
17
+ content_tag(:footer, class: cn(BASE, @extra_class), **@html_attrs) do
18
+ content_tag(:div, class: "container mx-auto px-6 py-10") do
19
+ concat columns_grid if @columns.any?
20
+ concat content if content?
21
+ concat copyright_row if @copyright
22
+ end
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def columns_grid
29
+ content_tag(:div, class: "grid gap-8 sm:grid-cols-2 md:grid-cols-#{[@columns.size, 4].min} mb-10") do
30
+ safe_join(@columns.map { |col| column(col) })
31
+ end
32
+ end
33
+
34
+ def column(col)
35
+ content_tag(:div) do
36
+ concat content_tag(:h3, col[:title], class: "mb-3 text-sm font-semibold text-foreground")
37
+ concat content_tag(:ul, class: "space-y-2") {
38
+ safe_join((col[:links] || []).map { |link|
39
+ content_tag(:li) { content_tag(:a, link[:label], href: link[:href], class: LINK) }
40
+ })
41
+ }
42
+ end
43
+ end
44
+
45
+ def copyright_row
46
+ content_tag(:div, class: "border-t pt-8 mt-8 text-center") do
47
+ content_tag(:p, @copyright, class: "text-sm text-muted-foreground")
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ # Wraps a label + input + optional hint and error message into a consistent field layout.
5
+ #
6
+ # Usage:
7
+ # <%= ui :form_field, label: "Email", error: @user.errors[:email].first do %>
8
+ # <%= ui :input, type: "email", name: "user[email]", id: "user_email" %>
9
+ # <% end %>
10
+ class FormFieldComponent < ApplicationComponent
11
+ def initialize(label: nil, hint: nil, error: nil, required: false, **html_attrs)
12
+ @label = label
13
+ @hint = hint
14
+ @error = error
15
+ @required = required
16
+ @extra_class = html_attrs.delete(:class)
17
+ @html_attrs = html_attrs
18
+ end
19
+
20
+ def call
21
+ content_tag(:div, class: cn("space-y-1.5", @extra_class), **@html_attrs) do
22
+ concat field_label if @label
23
+ concat content
24
+ concat hint_tag if @hint && @error.blank?
25
+ concat error_tag if @error.present?
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def field_label
32
+ content_tag(:label,
33
+ label_text,
34
+ class: "text-sm font-medium leading-none")
35
+ end
36
+
37
+ def label_text
38
+ return @label unless @required
39
+
40
+ safe_join([@label, content_tag(:span, " *", class: "text-destructive")])
41
+ end
42
+
43
+ def hint_tag
44
+ content_tag(:p, @hint, class: "text-xs text-muted-foreground")
45
+ end
46
+
47
+ def error_tag
48
+ content_tag(:p, @error, class: "text-xs text-destructive", role: "alert")
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class GalleryComponent < ApplicationComponent
5
+ # Responsive image grid with optional lightbox on click.
6
+ #
7
+ # Usage:
8
+ # ui :gallery, cols: 3 do |g|
9
+ # g.with_image(src: "/img/a.jpg", alt: "Photo A")
10
+ # g.with_image(src: "/img/b.jpg", alt: "Photo B", caption: "The coast")
11
+ # end
12
+
13
+ GRID_BASE = "grid gap-2"
14
+ GRID_COLS = {
15
+ 1 => "grid-cols-1", 2 => "grid-cols-2", 3 => "grid-cols-3",
16
+ 4 => "grid-cols-4", 5 => "grid-cols-5", 6 => "grid-cols-6"
17
+ }.freeze
18
+
19
+ ITEM_CLS = "group relative cursor-zoom-in overflow-hidden rounded-md"
20
+ IMG_CLS = "h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
21
+ CAP_CLS = "absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 px-3 py-2 " \
22
+ "text-sm text-white opacity-0 transition-opacity group-hover:opacity-100"
23
+
24
+ renders_many :images, "UI::GalleryComponent::ImageComponent"
25
+
26
+ # cols: number of grid columns (1–6, default: 3)
27
+ # lightbox: enable click-to-enlarge (default: true)
28
+ # aspect: Tailwind aspect-ratio class applied to each cell, e.g. "aspect-square"
29
+ def initialize(cols: 3, lightbox: true, aspect: "aspect-square", **html_attrs)
30
+ @cols = cols.to_i.clamp(1, 6)
31
+ @lightbox = lightbox
32
+ @aspect = aspect
33
+ @extra_class = html_attrs.delete(:class)
34
+ @html_attrs = html_attrs
35
+ end
36
+
37
+ def call
38
+ grid_cls = cn(GRID_BASE, GRID_COLS[@cols], @extra_class)
39
+
40
+ attrs = { class: grid_cls }
41
+ if @lightbox
42
+ attrs[:data] = { controller: "gallery",
43
+ action: "click@document->gallery#closeOnClickOutside keydown.escape@document->gallery#close" }
44
+ end
45
+ attrs.merge!(@html_attrs)
46
+
47
+ content_tag(:div, **attrs) do
48
+ safe_join(images.map { |img| wrap_image(img) })
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def wrap_image(img)
55
+ cell_attrs = { class: cn(ITEM_CLS, @aspect) }
56
+ if @lightbox
57
+ cell_attrs[:data] = { action: "click->gallery#open",
58
+ gallery_src_param: img.src,
59
+ gallery_alt_param: img.alt }
60
+ end
61
+ content_tag(:figure, **cell_attrs) do
62
+ concat img
63
+ concat content_tag(:figcaption, img.caption, class: CAP_CLS) if img.caption
64
+ end
65
+ end
66
+
67
+ class ImageComponent < ApplicationComponent
68
+ attr_reader :src, :alt, :caption
69
+
70
+ def initialize(src:, alt: "", caption: nil, **html_attrs)
71
+ @src = src
72
+ @alt = alt
73
+ @caption = caption
74
+ @html_attrs = html_attrs
75
+ end
76
+
77
+ def call
78
+ tag.img(src: @src, alt: @alt, class: GalleryComponent::IMG_CLS,
79
+ loading: "lazy", **@html_attrs)
80
+ end
81
+ end
82
+ end
83
+ end