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 +7 -0
- data/README.md +83 -0
- data/assets/image_uploader.css +37 -0
- data/lib/components/image_uploader_component.rb +233 -0
- data/lib/image_uploader.rb +7 -0
- metadata +41 -0
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
|
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: []
|