funicular-image-uploader 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e5e62d685ad7904d5736f83bf4f93236ae58519b662485de30c647ecddd160eb
4
+ data.tar.gz: caba1f7953324ccfe25086cc6914cea9761b2258e2450286e8ba9538f7ca00e0
5
+ SHA512:
6
+ metadata.gz: c64a67e635875da3e42dfef5bb1a796e4da7695627031d866d1513d52ec68245d5025ef57aa8371498fa860e755d5fa9449990af1259b94a83c087254eed529b
7
+ data.tar.gz: a98ea6fcb2f047f42b2c200d43915e2c9cce7a7aa5172ccc53c0abff83af9e849208ad588b26dc98f52cea1ec989e53fd8c3bdfaca9500ecb0466d7e8d5e0f67
data/README.md ADDED
@@ -0,0 +1,83 @@
1
+ # Funicular Image Uploader
2
+
3
+ Image upload component for Funicular.
4
+
5
+ The plugin only owns the browser-side interaction: file selection, object URL
6
+ preview, optional FormData upload, and display of the current image URL. The
7
+ Rails app owns persistence. The same component works with a controller that
8
+ stores bytes in SQLite or one that attaches the upload to Active Storage.
9
+
10
+ ## Install
11
+
12
+ ```ruby
13
+ group :funicular do
14
+ gem "funicular-image-uploader"
15
+ end
16
+ ```
17
+
18
+ Render plugin assets from the Rails layout:
19
+
20
+ ```erb
21
+ <%= funicular_plugin_include_tags %>
22
+ ```
23
+
24
+ ## Deferred upload with a profile form
25
+
26
+ Use `on_select` when an image should be submitted together with other form
27
+ fields:
28
+
29
+ ```ruby
30
+ component(
31
+ Funicular::Plugins::ImageUploader::Component,
32
+ src: current_user.has_avatar ? "/users/#{current_user.id}/avatar" : nil,
33
+ input_id: "avatar-input",
34
+ file_field: "avatar",
35
+ image_class: "w-24 h-24 rounded-full object-cover",
36
+ input_class: "w-full border rounded",
37
+ on_select: ->(file, preview_url) { @selected_avatar_file = file }
38
+ )
39
+ ```
40
+
41
+ The parent component can then send its form data with
42
+ `Funicular::FileUpload.upload_with_formdata`.
43
+
44
+ ## Standalone upload
45
+
46
+ Use `auto_upload: true` when the image has its own endpoint:
47
+
48
+ ```ruby
49
+ component(
50
+ Funicular::Plugins::ImageUploader::Component,
51
+ src: "/users/#{current_user.id}/avatar",
52
+ upload_url: "/users/#{current_user.id}/avatar",
53
+ file_field: "avatar",
54
+ auto_upload: true,
55
+ on_upload: ->(result) { puts "uploaded" },
56
+ on_error: ->(message, result) { puts message }
57
+ )
58
+ ```
59
+
60
+ The endpoint should return JSON. If it includes `image_url`, the component uses
61
+ that URL after upload. Override the key with `response_url_key: "url"`.
62
+
63
+ ## Active Storage endpoint example
64
+
65
+ ```ruby
66
+ class Users::AvatarsController < ApplicationController
67
+ before_action :require_login
68
+
69
+ def update
70
+ current_user.avatar.attach(params[:avatar])
71
+ render json: {
72
+ image_url: rails_blob_path(current_user.avatar, only_path: true)
73
+ }
74
+ end
75
+ end
76
+ ```
77
+
78
+ ```ruby
79
+ resource :avatar, only: [:update], controller: "users/avatars"
80
+ ```
81
+
82
+ For direct SQLite storage, read `params[:avatar]` in the controller and return
83
+ the URL that serves the stored bytes.
@@ -0,0 +1,37 @@
1
+ .funicular-image-uploader {
2
+ display: grid;
3
+ gap: 0.5rem;
4
+ }
5
+
6
+ .funicular-image-uploader__preview {
7
+ display: block;
8
+ }
9
+
10
+ .funicular-image-uploader__image {
11
+ width: 6rem;
12
+ height: 6rem;
13
+ border-radius: 9999px;
14
+ object-fit: cover;
15
+ }
16
+
17
+ .funicular-image-uploader__controls {
18
+ display: flex;
19
+ align-items: center;
20
+ gap: 0.5rem;
21
+ }
22
+
23
+ .funicular-image-uploader__placeholder {
24
+ width: 6rem;
25
+ height: 6rem;
26
+ border-radius: 9999px;
27
+ display: grid;
28
+ place-items: center;
29
+ background: #e5e7eb;
30
+ color: #4b5563;
31
+ font-weight: 600;
32
+ }
33
+
34
+ .funicular-image-uploader__error {
35
+ color: #b91c1c;
36
+ font-size: 0.875rem;
37
+ }
@@ -0,0 +1,233 @@
1
+ class ImageUploaderComponent < Funicular::Component
2
+ attr_reader :selected_file
3
+
4
+ def initialize_state
5
+ {
6
+ preview_url: nil,
7
+ uploaded_url: nil,
8
+ uploading: false,
9
+ error: nil,
10
+ cache_buster: Time.now.to_i
11
+ }
12
+ end
13
+
14
+ def handle_file_change(event = nil)
15
+ Funicular::FileUpload.select_file_with_preview(input_id) do |file, preview_url|
16
+ if file && preview_url
17
+ @selected_file = file
18
+ patch(preview_url: preview_url, error: nil)
19
+ emit_select(file, preview_url)
20
+ upload if props[:auto_upload]
21
+ else
22
+ @selected_file = nil
23
+ patch(preview_url: nil)
24
+ emit_select(nil, nil)
25
+ end
26
+ end
27
+ end
28
+
29
+ def clear
30
+ @selected_file = nil
31
+ patch(preview_url: nil, uploaded_url: nil, error: nil)
32
+ emit_select(nil, nil)
33
+ end
34
+
35
+ def upload(fields: nil, url: nil)
36
+ endpoint = url || props[:upload_url]
37
+ unless endpoint
38
+ patch(error: "upload_url is required")
39
+ emit_error("upload_url is required")
40
+ return
41
+ end
42
+
43
+ unless @selected_file
44
+ patch(error: "No file selected")
45
+ emit_error("No file selected")
46
+ return
47
+ end
48
+
49
+ patch(uploading: true, error: nil)
50
+
51
+ Funicular::FileUpload.upload_with_formdata(
52
+ endpoint,
53
+ fields: fields || upload_fields,
54
+ file_field: file_field,
55
+ file: @selected_file
56
+ ) do |result|
57
+ handle_upload_result(result)
58
+ end
59
+ end
60
+
61
+ def handle_upload_result(result)
62
+ if result.nil?
63
+ patch(uploading: false, error: "Failed to parse response")
64
+ emit_error("Failed to parse response")
65
+ return
66
+ end
67
+
68
+ if result["error"] || result["errors"]
69
+ message = result["error"] || result["errors"].join(", ")
70
+ patch(uploading: false, error: message)
71
+ emit_error(message, result)
72
+ return
73
+ end
74
+
75
+ @selected_file = nil
76
+ uploaded_url = result[response_url_key] || props[:src]
77
+ patch(
78
+ preview_url: nil,
79
+ uploaded_url: uploaded_url,
80
+ uploading: false,
81
+ error: nil,
82
+ cache_buster: Time.now.to_i
83
+ )
84
+ emit_upload(result)
85
+ end
86
+
87
+ def render
88
+ div(class: container_class) do
89
+ render_image
90
+ render_error
91
+ div(class: controls_class) do
92
+ input(
93
+ type: "file",
94
+ id: input_id,
95
+ accept: props[:accept] || "image/*",
96
+ onchange: :handle_file_change,
97
+ class: input_class
98
+ )
99
+ if clearable?
100
+ button(type: "button", class: clear_button_class, onclick: -> { clear }) do
101
+ props[:clear_label] || "Clear"
102
+ end
103
+ end
104
+ if props[:show_upload_button]
105
+ button(
106
+ type: "button",
107
+ class: upload_button_class,
108
+ disabled: state.uploading || @selected_file.nil?,
109
+ onclick: -> { upload }
110
+ ) do
111
+ state.uploading ? uploading_label : upload_label
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
117
+
118
+ private
119
+
120
+ def render_image
121
+ source = image_src
122
+ if source
123
+ div(class: preview_container_class) do
124
+ img(src: source, alt: alt_text, class: image_class)
125
+ end
126
+ elsif props[:placeholder]
127
+ div(class: placeholder_class) do
128
+ props[:placeholder]
129
+ end
130
+ end
131
+ end
132
+
133
+ def render_error
134
+ return unless state.error && props[:show_error] != false
135
+
136
+ div(class: error_class) { state.error }
137
+ end
138
+
139
+ def image_src
140
+ source = state.preview_url || state.uploaded_url || props[:src]
141
+ return nil if source.nil? || source.to_s == ""
142
+ return source if source.to_s.start_with?("blob:", "data:")
143
+ return source unless props[:cache_bust]
144
+
145
+ separator = source.to_s.include?("?") ? "&" : "?"
146
+ "#{source}#{separator}t=#{state.cache_buster}"
147
+ end
148
+
149
+ def input_id
150
+ props[:input_id] || "funicular-image-uploader-input"
151
+ end
152
+
153
+ def file_field
154
+ props[:file_field] || "image"
155
+ end
156
+
157
+ def upload_fields
158
+ fields = props[:fields]
159
+ return fields.call if fields.respond_to?(:call)
160
+ fields || {}
161
+ end
162
+
163
+ def response_url_key
164
+ props[:response_url_key] || "image_url"
165
+ end
166
+
167
+ def clearable?
168
+ props[:clearable] && (state.preview_url || state.uploaded_url || props[:src])
169
+ end
170
+
171
+ def emit_select(file, preview_url)
172
+ callback = props[:on_select]
173
+ callback.call(file, preview_url) if callback
174
+ end
175
+
176
+ def emit_upload(result)
177
+ callback = props[:on_upload]
178
+ callback.call(result) if callback
179
+ end
180
+
181
+ def emit_error(message, result = nil)
182
+ callback = props[:on_error]
183
+ callback.call(message, result) if callback
184
+ end
185
+
186
+ def container_class
187
+ props[:container_class] || "funicular-image-uploader"
188
+ end
189
+
190
+ def preview_container_class
191
+ props[:preview_container_class] || "funicular-image-uploader__preview"
192
+ end
193
+
194
+ def image_class
195
+ props[:image_class] || "funicular-image-uploader__image"
196
+ end
197
+
198
+ def input_class
199
+ props[:input_class] || "funicular-image-uploader__input"
200
+ end
201
+
202
+ def controls_class
203
+ props[:controls_class] || "funicular-image-uploader__controls"
204
+ end
205
+
206
+ def clear_button_class
207
+ props[:clear_button_class] || "funicular-image-uploader__clear"
208
+ end
209
+
210
+ def upload_button_class
211
+ props[:upload_button_class] || "funicular-image-uploader__upload"
212
+ end
213
+
214
+ def placeholder_class
215
+ props[:placeholder_class] || "funicular-image-uploader__placeholder"
216
+ end
217
+
218
+ def error_class
219
+ props[:error_class] || "funicular-image-uploader__error"
220
+ end
221
+
222
+ def upload_label
223
+ props[:upload_label] || "Upload"
224
+ end
225
+
226
+ def uploading_label
227
+ props[:uploading_label] || "Uploading..."
228
+ end
229
+
230
+ def alt_text
231
+ props[:alt] || "Selected image"
232
+ end
233
+ end
@@ -0,0 +1,7 @@
1
+ module Funicular
2
+ module Plugins
3
+ module ImageUploader
4
+ Component = ImageUploaderComponent
5
+ end
6
+ end
7
+ end
metadata ADDED
@@ -0,0 +1,41 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: funicular-image-uploader
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - HASUMI Hitoshi
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ executables: []
13
+ extensions: []
14
+ extra_rdoc_files: []
15
+ files:
16
+ - README.md
17
+ - assets/image_uploader.css
18
+ - lib/components/image_uploader_component.rb
19
+ - lib/image_uploader.rb
20
+ homepage: https://github.com/hasumikin/funicular-image-uploader
21
+ licenses:
22
+ - MIT
23
+ metadata: {}
24
+ rdoc_options: []
25
+ require_paths:
26
+ - lib
27
+ required_ruby_version: !ruby/object:Gem::Requirement
28
+ requirements:
29
+ - - ">="
30
+ - !ruby/object:Gem::Version
31
+ version: '3.2'
32
+ required_rubygems_version: !ruby/object:Gem::Requirement
33
+ requirements:
34
+ - - ">="
35
+ - !ruby/object:Gem::Version
36
+ version: '0'
37
+ requirements: []
38
+ rubygems_version: 4.0.10
39
+ specification_version: 4
40
+ summary: Image upload component for Funicular
41
+ test_files: []