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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +84 -0
- data/LICENSE.txt +21 -0
- data/README.md +198 -0
- data/lib/generators/view_primitives/add/add_generator.rb +110 -0
- data/lib/generators/view_primitives/add/templates/accordion/accordion_component.html.erb +10 -0
- data/lib/generators/view_primitives/add/templates/accordion/accordion_component.rb.tt +22 -0
- data/lib/generators/view_primitives/add/templates/accordion/accordion_controller.js +15 -0
- data/lib/generators/view_primitives/add/templates/accordion/accordion_item_component.rb.tt +47 -0
- data/lib/generators/view_primitives/add/templates/alert/alert_component.rb.tt +62 -0
- data/lib/generators/view_primitives/add/templates/alert_dialog/alert_dialog_component.rb.tt +55 -0
- data/lib/generators/view_primitives/add/templates/aspect_ratio/aspect_ratio_component.rb.tt +18 -0
- data/lib/generators/view_primitives/add/templates/audio/audio_component.rb.tt +51 -0
- data/lib/generators/view_primitives/add/templates/avatar/avatar_component.rb.tt +37 -0
- data/lib/generators/view_primitives/add/templates/badge/badge_component.rb.tt +35 -0
- data/lib/generators/view_primitives/add/templates/banner/banner_component.rb.tt +29 -0
- data/lib/generators/view_primitives/add/templates/bottom_nav/bottom_nav_component.rb.tt +38 -0
- data/lib/generators/view_primitives/add/templates/breadcrumb/breadcrumb_component.rb.tt +37 -0
- data/lib/generators/view_primitives/add/templates/button/button_component.rb.tt +61 -0
- data/lib/generators/view_primitives/add/templates/button_group/button_group_component.rb.tt +23 -0
- data/lib/generators/view_primitives/add/templates/calendar/calendar_component.rb.tt +121 -0
- data/lib/generators/view_primitives/add/templates/calendar/calendar_controller.js +86 -0
- data/lib/generators/view_primitives/add/templates/card/card_component.rb.tt +16 -0
- data/lib/generators/view_primitives/add/templates/card/card_content_component.rb.tt +16 -0
- data/lib/generators/view_primitives/add/templates/card/card_description_component.rb.tt +17 -0
- data/lib/generators/view_primitives/add/templates/card/card_footer_component.rb.tt +16 -0
- data/lib/generators/view_primitives/add/templates/card/card_header_component.rb.tt +17 -0
- data/lib/generators/view_primitives/add/templates/card/card_title_component.rb.tt +17 -0
- data/lib/generators/view_primitives/add/templates/carousel/carousel_component.rb.tt +102 -0
- data/lib/generators/view_primitives/add/templates/carousel/carousel_controller.js +48 -0
- data/lib/generators/view_primitives/add/templates/chart/chart_component.rb.tt +63 -0
- data/lib/generators/view_primitives/add/templates/chart/chart_controller.js +29 -0
- data/lib/generators/view_primitives/add/templates/chat_bubble/chat_bubble_component.rb.tt +53 -0
- data/lib/generators/view_primitives/add/templates/checkbox/checkbox_component.rb.tt +50 -0
- data/lib/generators/view_primitives/add/templates/collapsible/collapsible_component.rb.tt +31 -0
- data/lib/generators/view_primitives/add/templates/combobox/combobox_component.rb.tt +87 -0
- data/lib/generators/view_primitives/add/templates/combobox/combobox_controller.js +38 -0
- data/lib/generators/view_primitives/add/templates/command/command_component.rb.tt +85 -0
- data/lib/generators/view_primitives/add/templates/command/command_controller.js +50 -0
- data/lib/generators/view_primitives/add/templates/context_menu/context_menu_component.rb.tt +47 -0
- data/lib/generators/view_primitives/add/templates/context_menu/context_menu_controller.js +20 -0
- data/lib/generators/view_primitives/add/templates/data_table/data_table_component.rb.tt +163 -0
- data/lib/generators/view_primitives/add/templates/data_table/data_table_controller.js +115 -0
- data/lib/generators/view_primitives/add/templates/date_picker/date_picker_component.rb.tt +92 -0
- data/lib/generators/view_primitives/add/templates/date_picker/date_picker_controller.js +48 -0
- data/lib/generators/view_primitives/add/templates/device_mockup/device_mockup_component.rb.tt +65 -0
- data/lib/generators/view_primitives/add/templates/dialog/dialog_component.rb.tt +71 -0
- data/lib/generators/view_primitives/add/templates/dialog/dialog_controller.js +15 -0
- data/lib/generators/view_primitives/add/templates/drawer/drawer_component.rb.tt +62 -0
- data/lib/generators/view_primitives/add/templates/drawer/drawer_controller.js +15 -0
- data/lib/generators/view_primitives/add/templates/dropdown_menu/dropdown_controller.js +17 -0
- data/lib/generators/view_primitives/add/templates/dropdown_menu/dropdown_menu_component.rb.tt +53 -0
- data/lib/generators/view_primitives/add/templates/embed/embed_component.rb.tt +271 -0
- data/lib/generators/view_primitives/add/templates/embed/embed_controller.js +43 -0
- data/lib/generators/view_primitives/add/templates/figure/figure_component.rb.tt +24 -0
- data/lib/generators/view_primitives/add/templates/file_input/file_input_component.rb.tt +31 -0
- data/lib/generators/view_primitives/add/templates/floating_label/floating_label_component.rb.tt +54 -0
- data/lib/generators/view_primitives/add/templates/footer/footer_component.rb.tt +51 -0
- data/lib/generators/view_primitives/add/templates/form_field/form_field_component.rb.tt +51 -0
- data/lib/generators/view_primitives/add/templates/gallery/gallery_component.rb.tt +83 -0
- data/lib/generators/view_primitives/add/templates/gallery/gallery_controller.js +28 -0
- data/lib/generators/view_primitives/add/templates/hover_card/hover_card_component.rb.tt +43 -0
- data/lib/generators/view_primitives/add/templates/iframe/iframe_component.rb.tt +59 -0
- data/lib/generators/view_primitives/add/templates/image/image_component.rb.tt +38 -0
- data/lib/generators/view_primitives/add/templates/indicator/indicator_component.rb.tt +46 -0
- data/lib/generators/view_primitives/add/templates/input/input_component.rb.tt +28 -0
- data/lib/generators/view_primitives/add/templates/input_otp/input_otp_component.rb.tt +65 -0
- data/lib/generators/view_primitives/add/templates/input_otp/input_otp_controller.js +39 -0
- data/lib/generators/view_primitives/add/templates/kbd/kbd_component.rb.tt +21 -0
- data/lib/generators/view_primitives/add/templates/label/label_component.rb.tt +23 -0
- data/lib/generators/view_primitives/add/templates/list_group/list_group_component.rb.tt +16 -0
- data/lib/generators/view_primitives/add/templates/list_group/list_group_item_component.rb.tt +31 -0
- data/lib/generators/view_primitives/add/templates/map_area/map_area_component.rb.tt +72 -0
- data/lib/generators/view_primitives/add/templates/mega_menu/mega_menu_component.rb.tt +130 -0
- data/lib/generators/view_primitives/add/templates/mega_menu/mega_menu_controller.js +23 -0
- data/lib/generators/view_primitives/add/templates/menubar/menubar_component.rb.tt +33 -0
- data/lib/generators/view_primitives/add/templates/menubar/menubar_controller.js +34 -0
- data/lib/generators/view_primitives/add/templates/menubar/menubar_menu_component.rb.tt +34 -0
- data/lib/generators/view_primitives/add/templates/navbar/navbar_component.rb.tt +90 -0
- data/lib/generators/view_primitives/add/templates/navbar/navbar_controller.js +11 -0
- data/lib/generators/view_primitives/add/templates/navigation_menu/navigation_menu_component.rb.tt +132 -0
- data/lib/generators/view_primitives/add/templates/navigation_menu/navigation_menu_controller.js +25 -0
- data/lib/generators/view_primitives/add/templates/number_input/number_input_component.rb.tt +34 -0
- data/lib/generators/view_primitives/add/templates/pagination/pagination_component.rb.tt +97 -0
- data/lib/generators/view_primitives/add/templates/picture/picture_component.rb.tt +63 -0
- data/lib/generators/view_primitives/add/templates/popover/popover_component.rb.tt +56 -0
- data/lib/generators/view_primitives/add/templates/popover/popover_controller.js +17 -0
- data/lib/generators/view_primitives/add/templates/progress/progress_component.rb.tt +28 -0
- data/lib/generators/view_primitives/add/templates/qr_code/qr_code_component.rb.tt +39 -0
- data/lib/generators/view_primitives/add/templates/radio_group/radio_group_component.rb.tt +51 -0
- data/lib/generators/view_primitives/add/templates/range/range_component.rb.tt +40 -0
- data/lib/generators/view_primitives/add/templates/rating/rating_component.rb.tt +42 -0
- data/lib/generators/view_primitives/add/templates/rating_input/rating_controller.js +47 -0
- data/lib/generators/view_primitives/add/templates/rating_input/rating_input_component.rb.tt +79 -0
- data/lib/generators/view_primitives/add/templates/resizable/resizable_component.rb.tt +91 -0
- data/lib/generators/view_primitives/add/templates/resizable/resizable_controller.js +38 -0
- data/lib/generators/view_primitives/add/templates/scroll_area/scroll_area_component.rb.tt +41 -0
- data/lib/generators/view_primitives/add/templates/search_input/search_input_component.rb.tt +50 -0
- data/lib/generators/view_primitives/add/templates/select/select_component.rb.tt +45 -0
- data/lib/generators/view_primitives/add/templates/separator/separator_component.rb.tt +25 -0
- data/lib/generators/view_primitives/add/templates/sheet/sheet_component.rb.tt +78 -0
- data/lib/generators/view_primitives/add/templates/sheet/sheet_controller.js +15 -0
- data/lib/generators/view_primitives/add/templates/sidebar/sidebar_component.rb.tt +169 -0
- data/lib/generators/view_primitives/add/templates/sidebar/sidebar_controller.js +11 -0
- data/lib/generators/view_primitives/add/templates/skeleton/skeleton_component.rb.tt +16 -0
- data/lib/generators/view_primitives/add/templates/speed_dial/speed_dial_component.rb.tt +111 -0
- data/lib/generators/view_primitives/add/templates/speed_dial/speed_dial_controller.js +22 -0
- data/lib/generators/view_primitives/add/templates/spinner/spinner_component.rb.tt +27 -0
- data/lib/generators/view_primitives/add/templates/stepper/stepper_component.rb.tt +99 -0
- data/lib/generators/view_primitives/add/templates/switch/switch_component.rb.tt +51 -0
- data/lib/generators/view_primitives/add/templates/tabs/tabs_component.html.erb +50 -0
- data/lib/generators/view_primitives/add/templates/tabs/tabs_component.rb.tt +16 -0
- data/lib/generators/view_primitives/add/templates/tabs/tabs_controller.js +26 -0
- data/lib/generators/view_primitives/add/templates/tabs/tabs_item_component.rb.tt +15 -0
- data/lib/generators/view_primitives/add/templates/textarea/textarea_component.rb.tt +24 -0
- data/lib/generators/view_primitives/add/templates/timeline/timeline_component.rb.tt +78 -0
- data/lib/generators/view_primitives/add/templates/timepicker/timepicker_component.rb.tt +140 -0
- data/lib/generators/view_primitives/add/templates/timepicker/timepicker_controller.js +92 -0
- data/lib/generators/view_primitives/add/templates/toaster/toaster_component.rb.tt +152 -0
- data/lib/generators/view_primitives/add/templates/toaster/toaster_controller.js +88 -0
- data/lib/generators/view_primitives/add/templates/toggle/toggle_component.rb.tt +41 -0
- data/lib/generators/view_primitives/add/templates/toggle/toggle_controller.js +12 -0
- data/lib/generators/view_primitives/add/templates/toggle_group/toggle_group_component.rb.tt +32 -0
- data/lib/generators/view_primitives/add/templates/toggle_group/toggle_group_controller.js +38 -0
- data/lib/generators/view_primitives/add/templates/tooltip/tooltip_component.rb.tt +42 -0
- data/lib/generators/view_primitives/add/templates/video/video_component.rb.tt +92 -0
- data/lib/generators/view_primitives/add/templates/wysiwyg/wysiwyg_component.rb.tt +88 -0
- data/lib/generators/view_primitives/add/templates/wysiwyg/wysiwyg_controller.js +40 -0
- data/lib/generators/view_primitives/components.rb +62 -0
- data/lib/generators/view_primitives/detector.rb +43 -0
- data/lib/generators/view_primitives/install/install_generator.rb +65 -0
- data/lib/generators/view_primitives/install/templates/application_component.rb.tt +5 -0
- data/lib/generators/view_primitives/install/templates/view_primitives.css +67 -0
- data/lib/generators/view_primitives/list/list_generator.rb +25 -0
- data/lib/view_primitives/class_helper.rb +11 -0
- data/lib/view_primitives/component_helper.rb +20 -0
- data/lib/view_primitives/railtie.rb +21 -0
- data/lib/view_primitives/version.rb +5 -0
- data/lib/view_primitives.rb +12 -0
- 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
|
data/lib/generators/view_primitives/add/templates/floating_label/floating_label_component.rb.tt
ADDED
|
@@ -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
|