attache-rails 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: de21791c6e1a726e7de66bd344db5355d5251114
4
+ data.tar.gz: 8c6d37a9ff4018386915af71e20b3b87fd0ae207
5
+ SHA512:
6
+ metadata.gz: 9b78de46276eb9b77979c0e5782955b62921902491958c9b074747f4bccde6e5afe17e2d5851a7d1df4dfda3afdbd0425a9bcd884efcf5686a866b170990587e
7
+ data.tar.gz: c7ada47737eaa07cfeeb14e3078790b3e215b55fa4542f749cb61e16adbfd7ba0406b16596a2a87606fa00b4a45737ffc664ebb0b9cda984586dab1590b68c84
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2015 choonkeat
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,172 @@
1
+ # attache-rails
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/attache-rails.svg)](https://badge.fury.io/rb/attache-rails)
4
+ [![Build Status](https://travis-ci.org/choonkeat/attache-rails.svg?branch=master)](https://travis-ci.org/choonkeat/attache-rails)
5
+
6
+ Ruby on Rails / ActiveRecord integration for [attache server](https://github.com/choonkeat/attache) using [attache/api](https://github.com/choonkeat/attache-api)
7
+
8
+ ## Dependencies
9
+
10
+ [React](https://github.com/reactjs/react-rails), jQuery, Bootstrap 3
11
+
12
+ ## Installation
13
+
14
+ **WARNING: Please see upgrade notes below if you are upgrading from V2**
15
+
16
+ Install the attache-rails package from Rubygems:
17
+
18
+ ``` bash
19
+ gem install attache-rails
20
+ ```
21
+
22
+ Or add this to your `Gemfile`
23
+
24
+ ``` ruby
25
+ gem "attache-rails"
26
+ ```
27
+
28
+ Add the attache javascript to your `application.js`
29
+
30
+ ``` javascript
31
+ //= require attache
32
+ ```
33
+
34
+ If you want to customize the file upload look and feel, define your own react `<AttacheFilePreview/>` renderer *before* including the attache js. For example,
35
+
36
+ ``` javascript
37
+ //= require ./my_attache_file_preview.js
38
+ //= require attache
39
+ ```
40
+
41
+ The `AttacheRails.upgrade_fileinputs` idempotent function is setup to find all the elements with `enable-attache` css class and upgrade them to use the direct upload & preview javascript. If you wish to re-run this function any other time, e.g. hookup the `cocoon:after-insert` event, you may
42
+
43
+ ``` javascript
44
+ $(document).on('cocoon:after-insert', AttacheRails.upgrade_fileinputs);
45
+ ```
46
+
47
+
48
+ ## Usage
49
+
50
+ ### Database
51
+
52
+ To use `attache`, you only need to store the JSON attributes given to you after you've uploaded a file. So if you have an existing model, you only need to add a text column (PostgreSQL users see below)
53
+
54
+ ``` bash
55
+ rails generate migration AddPhotoPathToUsers photo:text
56
+ ```
57
+
58
+ To assign **multiple** images to **one** model, the same column can be used, although pluralized column name reads better
59
+
60
+ ``` bash
61
+ rails generate migration AddPhotoPathToUsers photos:text
62
+ ```
63
+
64
+ ### Model
65
+
66
+ In your model, define whether it `has_one_attache` or `has_many_attaches`
67
+
68
+ ``` ruby
69
+ class User < ActiveRecord::Base
70
+ has_many_attaches :photos
71
+ end
72
+ ```
73
+
74
+ ### New or Edit form
75
+
76
+ In your form, you would add some options to `file_field` using the `attache_options` helper method:
77
+
78
+ ``` slim
79
+ = f.file_field :photos, f.object.avatar_options('64x64#')
80
+ ```
81
+
82
+ If you were using `has_many_attaches` the file input will automatically allow multiple files, otherwise the file input will only accept 1 file.
83
+
84
+
85
+ NOTE: `64x64#` is just an example, you should define a suitable [geometry](http://www.imagemagick.org/Usage/resize/) for your form
86
+
87
+ ### Strong Parameters
88
+
89
+ You'd need to permit the new field in your controller. For example, a strong parameters definition may look like this in your `Users` controller
90
+
91
+ ``` ruby
92
+ def user_params
93
+ params.require(:user).permit(:name)
94
+ end
95
+ ```
96
+
97
+ If you're only accepting a single file upload, change it to
98
+
99
+ ``` ruby
100
+ def user_params
101
+ params.require(:user).permit(:name, :photo, attaches_discarded: [])
102
+ end
103
+ ```
104
+
105
+ If you're accepting multiple file uploads, change it to
106
+
107
+ ``` ruby
108
+ def user_params
109
+ params.require(:user).permit(:name, photos: [], attaches_discarded: [])
110
+ end
111
+ ```
112
+
113
+ NOTE: You do not have to manage `params[:attaches_discarded]` yourself. It is automatically managed for you between the frontend javascript and the ActiveRecord integration: files that are discarded will be removed from the attache server when you update or destroy your model.
114
+
115
+ ### Show
116
+
117
+ Use the `*_url` or `*_urls` methods (depending on whether you are accepting multiple files) to obtain full urls.
118
+
119
+ ``` slim
120
+ = image_tag @user.photo_url('100x100#')
121
+ ```
122
+
123
+ or
124
+
125
+ ``` slim
126
+ - @user.photos_urls('200x200#').each do |url|
127
+ = image_tag url
128
+ ```
129
+
130
+ ### Environment configs
131
+
132
+ `ATTACHE_URL` points to the attache server. e.g. `http://localhost:9292`
133
+
134
+ `ATTACHE_UPLOAD_DURATION` refers to the number of seconds before a signed upload request is considered expired, e.g. `600`
135
+
136
+ `ATTACHE_SECRET_KEY` is the shared secret with the `attache` server. e.g. `t0psecr3t`
137
+
138
+ * If this variable is not set, then upload requests will not be signed & `ATTACHE_UPLOAD_DURATION` will be ignored
139
+ * If this variable is set, it must be the same value as `SECRET_KEY` is set on the `attache` server
140
+
141
+ ### PostgreSQL
142
+
143
+ Take advantage of the [json support](http://guides.rubyonrails.org/active_record_postgresql.html#json) by using the [`json` or `jsonb` column types](http://www.postgresql.org/docs/9.4/static/functions-json.html) instead
144
+
145
+ ``` bash
146
+ rails generate migration AddPhotoPathToUsers photo:json
147
+ ```
148
+
149
+ This opens up the possibility to query inside the column, e.g.
150
+
151
+ ``` ruby
152
+ User.where("photo ->> 'content_type' = ?", 'image/png')
153
+ ```
154
+
155
+ ## Upgrading from v2
156
+
157
+ `json` values in the database column has changed in v3. Previously, we are working with json *strings*, now we are working with json *objects*, aka `Hash`. i.e.
158
+
159
+ - in previous version, `@user.photos` value could be something like `["{\"path\":\"dirname/file.jpg\"}"]`. Notice that it is an array of 1 `String`.
160
+ - in v3, the value would be `[{"path"=>"dirname/file.jpg"}]`. Notice that it is an array of 1 `Hash`
161
+
162
+ If you're upgrading from V2, we have a generator that will create a migration file to fixup the data
163
+
164
+ ```
165
+ rails g attache:rails:upgrade_v2_to_v3
166
+ ```
167
+
168
+ NOTE: It is highly recommended that developers verify the migration with a dump of the production data in a staging environment. Please take a look at the generated migration file.
169
+
170
+ # License
171
+
172
+ MIT
data/Rakefile ADDED
@@ -0,0 +1,5 @@
1
+ require "bundler/gem_tasks"
2
+ require 'rspec/core/rake_task'
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+ task :default => :spec
@@ -0,0 +1,4 @@
1
+ //= require attache/cors_upload
2
+ //= require attache/bootstrap3
3
+ //= require attache/file_input
4
+ //= require attache/ujs
@@ -0,0 +1,77 @@
1
+ if (typeof AttacheFilePreview === 'undefined') {
2
+
3
+ var AttacheFilePreview = React.createClass({displayName: "AttacheFilePreview",
4
+
5
+ getInitialState: function() {
6
+ return { srcWas: '' };
7
+ },
8
+
9
+ onSrcLoaded: function() {
10
+ this.setState({ srcWas: this.props.src });
11
+ },
12
+
13
+ render: function() {
14
+ var previewClassName = "attache-file-preview";
15
+
16
+ // progressbar
17
+ if (this.state.srcWas != this.props.src) {
18
+ previewClassName = previewClassName + " attache-loading";
19
+ var className = this.props.className || "progress-bar progress-bar-striped active" + (this.props.src ? " progress-bar-success" : "");
20
+ var pctString = this.props.pctString || (this.props.src ? 100 : this.props.percentLoaded) + "%";
21
+ var pctDesc = this.props.pctDesc || (this.props.src ? 'Loading...' : pctString);
22
+ var pctStyle = { width: pctString, minWidth: '3em' };
23
+ var progress = (
24
+ React.createElement("div", {className: "progress"},
25
+ React.createElement("div", {className: className, role: "progressbar", "aria-valuenow": this.props.percentLoaded, "aria-valuemin": "0", "aria-valuemax": "100", style: pctStyle},
26
+ pctDesc
27
+ )
28
+ )
29
+ );
30
+ }
31
+
32
+ // img tag
33
+ if (this.props.src) {
34
+ var img = React.createElement("img", {src: this.props.src, onLoad: this.onSrcLoaded});
35
+ }
36
+
37
+ // combined
38
+ return (
39
+ React.createElement("div", {className: previewClassName},
40
+ progress,
41
+ img,
42
+ React.createElement("div", {className: "clearfix"},
43
+ React.createElement("div", {className: "pull-left"}, this.props.filename),
44
+ React.createElement("a", {href: "#remove", className: "pull-right", onClick: this.props.onRemove, title: "Click to remove"}, "×")
45
+ )
46
+ )
47
+ );
48
+ }
49
+ });
50
+
51
+ }
52
+
53
+ if (typeof AttachePlaceholder === 'undefined') {
54
+
55
+ var AttachePlaceholder = React.createClass({displayName: "AttachePlaceholder",
56
+ render: function() {
57
+ return (
58
+ React.createElement("div", {className: "attache-file-preview"},
59
+ React.createElement("img", {src: this.props.src})
60
+ )
61
+ );
62
+ }
63
+ });
64
+
65
+ }
66
+
67
+ if (typeof AttacheHeader === 'undefined') {
68
+
69
+ var AttacheHeader = React.createClass({displayName: "AttacheHeader",
70
+ render: function() {
71
+ return (
72
+ React.createElement("noscript", null)
73
+ );
74
+ }
75
+ });
76
+
77
+ }
@@ -0,0 +1,77 @@
1
+ if (typeof AttacheFilePreview === 'undefined') {
2
+
3
+ var AttacheFilePreview = React.createClass({
4
+
5
+ getInitialState: function() {
6
+ return { srcWas: '' };
7
+ },
8
+
9
+ onSrcLoaded: function() {
10
+ this.setState({ srcWas: this.props.src });
11
+ },
12
+
13
+ render: function() {
14
+ var previewClassName = "attache-file-preview";
15
+
16
+ // progressbar
17
+ if (this.state.srcWas != this.props.src) {
18
+ previewClassName = previewClassName + " attache-loading";
19
+ var className = this.props.className || "progress-bar progress-bar-striped active" + (this.props.src ? " progress-bar-success" : "");
20
+ var pctString = this.props.pctString || (this.props.src ? 100 : this.props.percentLoaded) + "%";
21
+ var pctDesc = this.props.pctDesc || (this.props.src ? 'Loading...' : pctString);
22
+ var pctStyle = { width: pctString, minWidth: '3em' };
23
+ var progress = (
24
+ <div className="progress">
25
+ <div className={className} role="progressbar" aria-valuenow={this.props.percentLoaded} aria-valuemin="0" aria-valuemax="100" style={pctStyle}>
26
+ {pctDesc}
27
+ </div>
28
+ </div>
29
+ );
30
+ }
31
+
32
+ // img tag
33
+ if (this.props.src) {
34
+ var img = <img src={this.props.src} onLoad={this.onSrcLoaded} />;
35
+ }
36
+
37
+ // combined
38
+ return (
39
+ <div className={previewClassName}>
40
+ {progress}
41
+ {img}
42
+ <div className="clearfix">
43
+ <div className="pull-left">{this.props.filename}</div>
44
+ <a href="#remove" className="pull-right" onClick={this.props.onRemove} title="Click to remove">&times;</a>
45
+ </div>
46
+ </div>
47
+ );
48
+ }
49
+ });
50
+
51
+ }
52
+
53
+ if (typeof AttachePlaceholder === 'undefined') {
54
+
55
+ var AttachePlaceholder = React.createClass({
56
+ render: function() {
57
+ return (
58
+ <div className="attache-file-preview">
59
+ <img src={this.props.src} />
60
+ </div>
61
+ );
62
+ }
63
+ });
64
+
65
+ }
66
+
67
+ if (typeof AttacheHeader === 'undefined') {
68
+
69
+ var AttacheHeader = React.createClass({
70
+ render: function() {
71
+ return (
72
+ <noscript />
73
+ );
74
+ }
75
+ });
76
+
77
+ }
@@ -0,0 +1,87 @@
1
+ var AttacheCORSUpload = (function() {
2
+ var counter = 0;
3
+
4
+ AttacheCORSUpload.prototype.onComplete = function(uid, json) { };
5
+ AttacheCORSUpload.prototype.onProgress = function(uid, json) { };
6
+ AttacheCORSUpload.prototype.onError = function(uid, status) { alert(status); };
7
+ AttacheCORSUpload.prototype.createLocalThumbnail = function() { }; // for overwriting
8
+
9
+ function AttacheCORSUpload(options) {
10
+ if (options == null) options = {};
11
+ for (option in options) {
12
+ this[option] = options[option];
13
+ }
14
+ this.handleFileSelect(options.file_element, options.files);
15
+ }
16
+
17
+ AttacheCORSUpload.prototype.handleFileSelect = function(file_element, files) {
18
+ var f, output, _i, _len, _results, url, $ele, prefix;
19
+ $ele = $(file_element);
20
+ url = $ele.data('uploadurl');
21
+ if ($ele.data('hmac')) {
22
+ url = url +
23
+ "?hmac=" + encodeURIComponent($ele.data('hmac')) +
24
+ "&uuid=" + encodeURIComponent($ele.data('uuid')) +
25
+ "&expiration=" + encodeURIComponent($ele.data('expiration')) +
26
+ ""
27
+ }
28
+
29
+ prefix = Date.now() + "_";
30
+ output = [];
31
+ _results = [];
32
+ for (_i = 0, _len = files.length; _i < _len; _i++) {
33
+ f = files[_i];
34
+ this.createLocalThumbnail(f); // if any
35
+ f.uid = prefix + (counter++);
36
+ this.onProgress(f.uid, { src: f.src, filename: f.name, percentLoaded: 0, bytesLoaded: 0, bytesTotal: f.size });
37
+ _results.push(this.performUpload(f, url));
38
+ }
39
+ return _results;
40
+ };
41
+
42
+ AttacheCORSUpload.prototype.createCORSRequest = function(method, url) {
43
+ var xhr;
44
+ xhr = new XMLHttpRequest();
45
+ if (xhr.withCredentials != null) {
46
+ xhr.open(method, url, true);
47
+ } else if (typeof XDomainRequest !== "undefined") {
48
+ xhr = new XDomainRequest();
49
+ xhr.open(method, url);
50
+ } else {
51
+ xhr = null;
52
+ }
53
+ return xhr;
54
+ };
55
+
56
+ AttacheCORSUpload.prototype.performUpload = function(file, url) {
57
+ var this_s3upload, xhr;
58
+ this_s3upload = this;
59
+ url = url + (url.indexOf('?') == -1 ? '?' : '&') + 'file=' + encodeURIComponent(file.name);
60
+ xhr = this.createCORSRequest('PUT', url);
61
+ if (!xhr) {
62
+ this.onError(file.uid, 'CORS not supported');
63
+ } else {
64
+ xhr.onload = function(e) {
65
+ if (xhr.status === 200) {
66
+ this_s3upload.onComplete(file.uid, JSON.parse(e.target.responseText));
67
+ } else {
68
+ return this_s3upload.onError(file.uid, xhr.status + ' ' + xhr.statusText);
69
+ }
70
+ };
71
+ xhr.onerror = function() {
72
+ return this_s3upload.onError(file.uid, 'Unable to reach server');
73
+ };
74
+ xhr.upload.onprogress = function(e) {
75
+ var percentLoaded;
76
+ if (e.lengthComputable) {
77
+ percentLoaded = Math.round((e.loaded / e.total) * 100);
78
+ return this_s3upload.onProgress(file.uid, { src: file.src, filename: file.name, percentLoaded: percentLoaded, bytesLoaded: e.loaded, bytesTotal: e.total });
79
+ }
80
+ };
81
+ }
82
+ return xhr.send(file);
83
+ };
84
+
85
+ return AttacheCORSUpload;
86
+
87
+ })();
@@ -0,0 +1,152 @@
1
+ var AttacheFileInput = React.createClass({displayName: "AttacheFileInput",
2
+
3
+ getInitialState: function() {
4
+ var files = {};
5
+ if (this.props['data-value']) $.each(JSON.parse(this.props['data-value']), function(uid, json) {
6
+ if (json) files[uid] = json;
7
+ });
8
+ return {files: files, attaches_discarded: [], uploading: 0 };
9
+ },
10
+
11
+ onRemove: function(uid, e) {
12
+ e.preventDefault();
13
+ e.stopPropagation();
14
+
15
+ var fieldname = this.getDOMNode().firstChild.name; // when 'user[avatar]'
16
+ var newfield = fieldname.replace(/\w+\](\[\]|)$/, 'attaches_discarded][]'); // become 'user[attaches_discarded][]'
17
+
18
+ this.state.attaches_discarded.push({ fieldname: newfield, path: this.state.files[uid].path });
19
+ delete this.state.files[uid];
20
+
21
+ this.setState(this.state);
22
+ },
23
+
24
+ performUpload: function(file_element, files) {
25
+ // user cancelled file chooser dialog. ignore
26
+ if (! files || files.length == 0) return;
27
+ if (! this.props.multiple) {
28
+ this.state.files = {};
29
+ files = [files[0]]; // array of 1 element
30
+ }
31
+
32
+ this.setState(this.state);
33
+ // upload the file via CORS
34
+ var that = this;
35
+
36
+ that.state.uploading = that.state.uploading + files.length;
37
+ if (! that.state.submit_buttons) that.state.submit_buttons = $("button,input[type='submit']", $(file_element).parents('form')[0]).filter(':not(:disabled)');
38
+
39
+ new AttacheCORSUpload({
40
+ file_element: file_element,
41
+ files: files,
42
+ onComplete: function() {
43
+ that.state.uploading--;
44
+ that.setFileValue.apply(this, arguments);
45
+ },
46
+ onProgress: this.setFileValue,
47
+ onError: function(uid, status) {
48
+ that.state.uploading--;
49
+ that.setFileValue(uid, { pctString: '90%', pctDesc: status, className: 'progress-bar progress-bar-danger' });
50
+ }
51
+ });
52
+
53
+ // we don't want the file binary to be uploaded in the main form
54
+ // so the actual file input is neutered
55
+ file_element.value = '';
56
+ },
57
+
58
+ onChange: function() {
59
+ var file_element = this.getDOMNode().firstChild;
60
+ this.performUpload(file_element, file_element && file_element.files);
61
+ },
62
+
63
+ onDragOver: function(e) {
64
+ e.stopPropagation();
65
+ e.preventDefault();
66
+ $(this.getDOMNode()).addClass('attache-dragover');
67
+ },
68
+
69
+ onDragLeave: function(e) {
70
+ e.stopPropagation();
71
+ e.preventDefault();
72
+ $(this.getDOMNode()).removeClass('attache-dragover');
73
+ },
74
+
75
+ onDrop: function(e) {
76
+ e.stopPropagation();
77
+ e.preventDefault();
78
+ var file_element = this.getDOMNode().firstChild;
79
+ this.performUpload(file_element, e.target.files || e.dataTransfer.files);
80
+ $(this.getDOMNode()).removeClass('attache-dragover');
81
+ },
82
+
83
+ setFileValue: function(key, value) {
84
+ this.state.files[key] = value;
85
+ this.setState(this.state);
86
+ },
87
+
88
+ render: function() {
89
+ var that = this;
90
+
91
+ if (that.state.uploading > 0) {
92
+ that.state.submit_buttons.attr('disabled', true);
93
+ } else if (that.state.submit_buttons) {
94
+ that.state.submit_buttons.attr('disabled', null);
95
+ }
96
+
97
+ var Header = eval(this.props['data-header-component'] || 'AttacheHeader');
98
+ var Preview = eval(this.props['data-preview-component'] || 'AttacheFilePreview');
99
+ var Placeholder = eval(this.props['data-placeholder-component'] || 'AttachePlaceholder');
100
+
101
+ var previews = [];
102
+ $.each(that.state.files, function(key, result) {
103
+ // json is input[value], drop non essential values
104
+ var copy = JSON.parse(JSON.stringify(result));
105
+ delete copy.src;
106
+ delete copy.filename;
107
+ var json = JSON.stringify(copy);
108
+ //
109
+ result.multiple = that.props.multiple;
110
+ if (result.path) {
111
+ var parts = result.path.split('/');
112
+ parts.splice(parts.length-1, 0, encodeURIComponent(that.props['data-geometry'] || '128x128#'));
113
+ result.src = that.props['data-downloadurl'] + '/' + parts.join('/');
114
+ result.filename = result.src.split('/').pop().split(/[#?]/).shift();
115
+ }
116
+ var previewKey = "preview" + key;
117
+ previews.push(
118
+ React.createElement("div", {key: previewKey, className: "attache-file-input"},
119
+ React.createElement("input", {type: "hidden", name: that.props.name, value: json, readOnly: "true"}),
120
+ React.createElement(Preview, React.__spread({}, result, {key: key, onRemove: that.onRemove.bind(that, key)}))
121
+ )
122
+ );
123
+ });
124
+
125
+ var placeholders = [];
126
+ if (previews.length == 0 && that.props['data-placeholder']) $.each(JSON.parse(that.props['data-placeholder']), function(uid, src) {
127
+ placeholders.push(
128
+ React.createElement(Placeholder, React.__spread({key: "placeholder"}, that.props, {src: src}))
129
+ );
130
+ });
131
+
132
+ var discards = [];
133
+ $.each(that.state.attaches_discarded, function(index, discard) {
134
+ var discardKey = "discard" + discard.path;
135
+ discards.push(
136
+ React.createElement("input", {key: discardKey, type: "hidden", name: discard.fieldname, value: discard.path})
137
+ );
138
+ });
139
+
140
+ var className = ["attache-file-selector", "attache-placeholders-count-" + placeholders.length, "attache-previews-count-" + previews.length, this.props['data-classname']].join(' ').trim();
141
+ return (
142
+ React.createElement("label", {htmlFor: that.props.id, className: className, onDragOver: this.onDragOver, onDragLeave: this.onDragLeave, onDrop: this.onDrop},
143
+ React.createElement("input", React.__spread({type: "file"}, that.props, {onChange: this.onChange})),
144
+ React.createElement("input", {type: "hidden", name: that.props.name, value: ""}),
145
+ React.createElement(Header, React.__spread({}, that.props)),
146
+ previews,
147
+ placeholders,
148
+ discards
149
+ )
150
+ );
151
+ }
152
+ });
@@ -0,0 +1,152 @@
1
+ var AttacheFileInput = React.createClass({
2
+
3
+ getInitialState: function() {
4
+ var files = {};
5
+ if (this.props['data-value']) $.each(JSON.parse(this.props['data-value']), function(uid, json) {
6
+ if (json) files[uid] = json;
7
+ });
8
+ return {files: files, attaches_discarded: [], uploading: 0 };
9
+ },
10
+
11
+ onRemove: function(uid, e) {
12
+ e.preventDefault();
13
+ e.stopPropagation();
14
+
15
+ var fieldname = this.getDOMNode().firstChild.name; // when 'user[avatar]'
16
+ var newfield = fieldname.replace(/\w+\](\[\]|)$/, 'attaches_discarded][]'); // become 'user[attaches_discarded][]'
17
+
18
+ this.state.attaches_discarded.push({ fieldname: newfield, path: this.state.files[uid].path });
19
+ delete this.state.files[uid];
20
+
21
+ this.setState(this.state);
22
+ },
23
+
24
+ performUpload: function(file_element, files) {
25
+ // user cancelled file chooser dialog. ignore
26
+ if (! files || files.length == 0) return;
27
+ if (! this.props.multiple) {
28
+ this.state.files = {};
29
+ files = [files[0]]; // array of 1 element
30
+ }
31
+
32
+ this.setState(this.state);
33
+ // upload the file via CORS
34
+ var that = this;
35
+
36
+ that.state.uploading = that.state.uploading + files.length;
37
+ if (! that.state.submit_buttons) that.state.submit_buttons = $("button,input[type='submit']", $(file_element).parents('form')[0]).filter(':not(:disabled)');
38
+
39
+ new AttacheCORSUpload({
40
+ file_element: file_element,
41
+ files: files,
42
+ onComplete: function() {
43
+ that.state.uploading--;
44
+ that.setFileValue.apply(this, arguments);
45
+ },
46
+ onProgress: this.setFileValue,
47
+ onError: function(uid, status) {
48
+ that.state.uploading--;
49
+ that.setFileValue(uid, { pctString: '90%', pctDesc: status, className: 'progress-bar progress-bar-danger' });
50
+ }
51
+ });
52
+
53
+ // we don't want the file binary to be uploaded in the main form
54
+ // so the actual file input is neutered
55
+ file_element.value = '';
56
+ },
57
+
58
+ onChange: function() {
59
+ var file_element = this.getDOMNode().firstChild;
60
+ this.performUpload(file_element, file_element && file_element.files);
61
+ },
62
+
63
+ onDragOver: function(e) {
64
+ e.stopPropagation();
65
+ e.preventDefault();
66
+ $(this.getDOMNode()).addClass('attache-dragover');
67
+ },
68
+
69
+ onDragLeave: function(e) {
70
+ e.stopPropagation();
71
+ e.preventDefault();
72
+ $(this.getDOMNode()).removeClass('attache-dragover');
73
+ },
74
+
75
+ onDrop: function(e) {
76
+ e.stopPropagation();
77
+ e.preventDefault();
78
+ var file_element = this.getDOMNode().firstChild;
79
+ this.performUpload(file_element, e.target.files || e.dataTransfer.files);
80
+ $(this.getDOMNode()).removeClass('attache-dragover');
81
+ },
82
+
83
+ setFileValue: function(key, value) {
84
+ this.state.files[key] = value;
85
+ this.setState(this.state);
86
+ },
87
+
88
+ render: function() {
89
+ var that = this;
90
+
91
+ if (that.state.uploading > 0) {
92
+ that.state.submit_buttons.attr('disabled', true);
93
+ } else if (that.state.submit_buttons) {
94
+ that.state.submit_buttons.attr('disabled', null);
95
+ }
96
+
97
+ var Header = eval(this.props['data-header-component'] || 'AttacheHeader');
98
+ var Preview = eval(this.props['data-preview-component'] || 'AttacheFilePreview');
99
+ var Placeholder = eval(this.props['data-placeholder-component'] || 'AttachePlaceholder');
100
+
101
+ var previews = [];
102
+ $.each(that.state.files, function(key, result) {
103
+ // json is input[value], drop non essential values
104
+ var copy = JSON.parse(JSON.stringify(result));
105
+ delete copy.src;
106
+ delete copy.filename;
107
+ var json = JSON.stringify(copy);
108
+ //
109
+ result.multiple = that.props.multiple;
110
+ if (result.path) {
111
+ var parts = result.path.split('/');
112
+ parts.splice(parts.length-1, 0, encodeURIComponent(that.props['data-geometry'] || '128x128#'));
113
+ result.src = that.props['data-downloadurl'] + '/' + parts.join('/');
114
+ result.filename = result.src.split('/').pop().split(/[#?]/).shift();
115
+ }
116
+ var previewKey = "preview" + key;
117
+ previews.push(
118
+ <div key={previewKey} className="attache-file-input">
119
+ <input type="hidden" name={that.props.name} value={json} readOnly="true" />
120
+ <Preview {...result} key={key} onRemove={that.onRemove.bind(that, key)}/>
121
+ </div>
122
+ );
123
+ });
124
+
125
+ var placeholders = [];
126
+ if (previews.length == 0 && that.props['data-placeholder']) $.each(JSON.parse(that.props['data-placeholder']), function(uid, src) {
127
+ placeholders.push(
128
+ <Placeholder key="placeholder" {...that.props} src={src} />
129
+ );
130
+ });
131
+
132
+ var discards = [];
133
+ $.each(that.state.attaches_discarded, function(index, discard) {
134
+ var discardKey = "discard" + discard.path;
135
+ discards.push(
136
+ <input key={discardKey} type="hidden" name={discard.fieldname} value={discard.path} />
137
+ );
138
+ });
139
+
140
+ var className = ["attache-file-selector", "attache-placeholders-count-" + placeholders.length, "attache-previews-count-" + previews.length, this.props['data-classname']].join(' ').trim();
141
+ return (
142
+ <label htmlFor={that.props.id} className={className} onDragOver={this.onDragOver} onDragLeave={this.onDragLeave} onDrop={this.onDrop}>
143
+ <input type="file" {...that.props} onChange={this.onChange}/>
144
+ <input type="hidden" name={that.props.name} value="" />
145
+ <Header {...that.props} />
146
+ {previews}
147
+ {placeholders}
148
+ {discards}
149
+ </label>
150
+ );
151
+ }
152
+ });
@@ -0,0 +1,23 @@
1
+ window.AttacheRails = {
2
+ upgrade_fileinputs: function() {
3
+ var safeWords = { 'class': 'className', 'for': 'htmlFor' };
4
+ var sel = document.getElementsByClassName('enable-attache');
5
+ var ele, attrs, name, value;
6
+ for (var i = sel.length-1; i >= 0; i--) {
7
+ ele = sel[i];
8
+ attrs = {};
9
+ for (var j = 0; j < ele.attributes.length; j++) {
10
+ name = ele.attributes[j].name;
11
+ value = ele.attributes[j].value;
12
+ if (name === 'class') value = value.replace('enable-attache', 'attache-enabled');
13
+ name = safeWords[name] || name;
14
+ attrs[name] = value;
15
+ }
16
+ var wrap = document.createElement('div');
17
+ ele.parentNode.replaceChild(wrap, ele); // ele.parentNode.insertBefore(wrap, ele.nextSibling);
18
+ React.render(React.createElement(AttacheFileInput, React.__spread({}, attrs)), wrap);
19
+ }
20
+ }
21
+ };
22
+
23
+ $(document).on('page:change', AttacheRails.upgrade_fileinputs);
@@ -0,0 +1,13 @@
1
+ module Attache
2
+ module Rails
3
+ ATTACHE_URL = ENV.fetch('ATTACHE_URL') { "http://localhost:9292" }
4
+ ATTACHE_UPLOAD_URL = ENV.fetch('ATTACHE_UPLOAD_URL') { "#{ATTACHE_URL}/upload" }
5
+ ATTACHE_DOWNLOAD_URL = ENV.fetch('ATTACHE_DOWNLOAD_URL') { "#{ATTACHE_URL}/view" }
6
+ ATTACHE_DELETE_URL = ENV.fetch('ATTACHE_DELETE_URL') { "#{ATTACHE_URL}/delete" }
7
+ ATTACHE_UPLOAD_DURATION = ENV.fetch('ATTACHE_UPLOAD_DURATION') { 3.hours }.to_i # expires signed upload form
8
+ end
9
+ end
10
+
11
+ require "attache/rails/engine"
12
+ require "attache/rails/model"
13
+ require "attache/api/test" if defined?(Rails) && Rails.env.test?
@@ -0,0 +1,7 @@
1
+ module Attache
2
+ module Rails
3
+ class Engine < ::Rails::Engine
4
+ isolate_namespace Attache::Rails
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,57 @@
1
+ require "cgi"
2
+ require "uri"
3
+ require "httpclient"
4
+ require "attache/api"
5
+
6
+ module Attache
7
+ module Rails
8
+ module Model
9
+ include Attache::API::Model
10
+
11
+ def self.included(base)
12
+ # has_one_attache, has_many_attaches
13
+ base.extend ClassMethods
14
+
15
+ # `discard` management
16
+ base.class_eval do
17
+ attr_accessor :attaches_discarded
18
+ after_commit if: :attaches_discarded do |instance|
19
+ instance.attaches_discard!(instance.attaches_discarded)
20
+ end
21
+ end
22
+ end
23
+
24
+ module ClassMethods
25
+ def attache_setup_column(name)
26
+ case coltype = column_for_attribute(name).type
27
+ when :text, :string, :binary
28
+ serialize name, JSON
29
+ end
30
+ rescue Exception
31
+ end
32
+
33
+ def has_one_attache(name)
34
+ attache_setup_column(name)
35
+ define_method "#{name}_options", -> (geometry, options = {}) { Hash(class: 'enable-attache', multiple: false).merge(attache_field_options(self.send(name), geometry, options)) }
36
+ define_method "#{name}_url", -> (geometry) { attache_field_urls(self.send(name), geometry).try(:first) }
37
+ define_method "#{name}_attributes", -> (geometry) { attache_field_attributes(self.send(name), geometry).try(:first) }
38
+ define_method "#{name}=", -> (value) { super(attache_field_set(Array.wrap(value)).try(:first)) }
39
+ after_update -> { self.attaches_discarded ||= []; attache_mark_for_discarding(self.send("#{name}_was"), self.send("#{name}"), self.attaches_discarded) }
40
+ after_destroy -> { self.attaches_discarded ||= []; attache_mark_for_discarding(self.send("#{name}_was"), [], self.attaches_discarded) }
41
+ end
42
+
43
+ def has_many_attaches(name)
44
+ attache_setup_column(name)
45
+ define_method "#{name}_options", -> (geometry, options = {}) { Hash(class: 'enable-attache', multiple: true).merge(attache_field_options(self.send(name), geometry, options)) }
46
+ define_method "#{name}_urls", -> (geometry) { attache_field_urls(self.send(name), geometry) }
47
+ define_method "#{name}_attributes", -> (geometry) { attache_field_attributes(self.send(name), geometry) }
48
+ define_method "#{name}=", -> (value) { super(attache_field_set(Array.wrap(value))) }
49
+ after_update -> { self.attaches_discarded ||= []; attache_mark_for_discarding(self.send("#{name}_was"), self.send("#{name}"), self.attaches_discarded) }
50
+ after_destroy -> { self.attaches_discarded ||= []; attache_mark_for_discarding(self.send("#{name}_was"), [], self.attaches_discarded) }
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+
57
+ ActiveRecord::Base.send(:include, Attache::Rails::Model)
@@ -0,0 +1,5 @@
1
+ module Attache
2
+ module Rails
3
+ VERSION = "0.4.0"
4
+ end
5
+ end
@@ -0,0 +1,45 @@
1
+ class <%= migration_class_name %> < ActiveRecord::Migration
2
+ <%- ($has_one_attache + $has_many_attaches).each do |(klass, column)| -%>
3
+ class <%= klass %> < ActiveRecord::Base; serialize :<%= column %>, JSON; end
4
+ <%- end -%>
5
+
6
+ def self.up
7
+ # has_one_attache
8
+ <%- $has_one_attache.each do |(klass, column)| -%>
9
+ <%= klass %>.where.not(<%= column %>: [nil, ""]).find_each do |obj|
10
+ if obj[:<%= column %>].kind_of?(String)
11
+ obj.update(<%= column %>: JSON.parse(obj[:<%= column %>]))
12
+ end
13
+ end
14
+ <%- end -%>
15
+
16
+ # has_many_attaches
17
+ <%- $has_many_attaches.each do |(klass, column)| -%>
18
+ <%= klass %>.where.not(<%= column %>: [nil, ""]).find_each do |obj|
19
+ if obj[:<%= column %>].first.kind_of?(String)
20
+ obj.update(<%= column %>: obj[:<%= column %>].collect {|v| JSON.parse(v) })
21
+ end
22
+ end
23
+ <%- end -%>
24
+ end
25
+
26
+ def self.down
27
+ # has_one_attache
28
+ <%- $has_one_attache.each do |(klass, column)| -%>
29
+ <%= klass %>.where.not(<%= column %>: [nil, ""]).find_each do |obj|
30
+ if obj[:<%= column %>].kind_of?(Hash)
31
+ obj.update(<%= column %>: obj[:<%= column %>].to_json)
32
+ end
33
+ end
34
+ <%- end -%>
35
+
36
+ # has_many_attaches
37
+ <%- $has_many_attaches.each do |(klass, column)| -%>
38
+ <%= klass %>.where.not(<%= column %>: [nil, ""]).find_each do |obj|
39
+ if obj[:<%= column %>].first.kind_of?(Hash)
40
+ obj.update(<%= column %>: obj[:<%= column %>].collect {|v| v.to_json })
41
+ end
42
+ end
43
+ <%- end -%>
44
+ end
45
+ end
@@ -0,0 +1,57 @@
1
+ require 'rails/generators/active_record'
2
+
3
+ module Attache
4
+ module Rails
5
+ class UpgradeV2ToV3Generator < ::Rails::Generators::Base
6
+ include ::Rails::Generators::Migration
7
+
8
+ def self.next_migration_number(dir)
9
+ ActiveRecord::Generators::Base.next_migration_number(dir)
10
+ end
11
+
12
+ def self.source_root
13
+ @source_root ||= File.expand_path('../templates', __FILE__)
14
+ end
15
+
16
+ def generate_migration
17
+ migration_template "upgrade_v2_to_v3_migration.rb.erb", "db/migrate/#{migration_file_name}"
18
+ end
19
+
20
+ def migration_name
21
+ "UpgradeAttacheFieldsFromV2ToV3"
22
+ end
23
+
24
+ def migration_file_name
25
+ "#{migration_name.underscore}.rb"
26
+ end
27
+
28
+ def migration_class_name
29
+ migration_name.camelize
30
+ end
31
+
32
+ $has_one_attache = []
33
+ $has_many_attaches = []
34
+
35
+ ActiveRecord::Base.class_eval do
36
+ def self.has_one_attache(name)
37
+ $has_one_attache.push([self.name, name])
38
+ end
39
+
40
+ def self.has_many_attaches(name)
41
+ $has_many_attaches.push([self.name, name])
42
+ end
43
+ end
44
+
45
+ # Rails::ConsoleMethods#reload!
46
+ ActionDispatch::Reloader.cleanup!
47
+ ActionDispatch::Reloader.prepare!
48
+
49
+ ActiveRecord::Base.connection.tables.each do |table|
50
+ if klass = table.classify.safe_constantize
51
+ else
52
+ puts "skipped #{table}"
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
metadata ADDED
@@ -0,0 +1,158 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: attache-rails
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.4.0
5
+ platform: ruby
6
+ authors:
7
+ - choonkeat
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2015-11-08 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 4.0.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: 4.0.0
27
+ - !ruby/object:Gem::Dependency
28
+ name: attache-api
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 0.2.0
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 0.2.0
41
+ - !ruby/object:Gem::Dependency
42
+ name: httpclient
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec-rails
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: guard-rspec
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: sqlite3
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: factory_girl_rails
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ description:
112
+ email:
113
+ - choonkeat@gmail.com
114
+ executables: []
115
+ extensions: []
116
+ extra_rdoc_files: []
117
+ files:
118
+ - MIT-LICENSE
119
+ - README.md
120
+ - Rakefile
121
+ - app/assets/javascripts/attache.js
122
+ - app/assets/javascripts/attache/bootstrap3.js
123
+ - app/assets/javascripts/attache/bootstrap3.js.jsx
124
+ - app/assets/javascripts/attache/cors_upload.js
125
+ - app/assets/javascripts/attache/file_input.js
126
+ - app/assets/javascripts/attache/file_input.js.jsx
127
+ - app/assets/javascripts/attache/ujs.js
128
+ - lib/attache/rails.rb
129
+ - lib/attache/rails/engine.rb
130
+ - lib/attache/rails/model.rb
131
+ - lib/attache/rails/version.rb
132
+ - lib/generators/attache/rails/templates/upgrade_v2_to_v3_migration.rb.erb
133
+ - lib/generators/attache/rails/upgrade_v2_to_v3_generator.rb
134
+ homepage: https://github.com/choonkeat/attache-rails
135
+ licenses:
136
+ - MIT
137
+ metadata: {}
138
+ post_install_message:
139
+ rdoc_options: []
140
+ require_paths:
141
+ - lib
142
+ required_ruby_version: !ruby/object:Gem::Requirement
143
+ requirements:
144
+ - - ">="
145
+ - !ruby/object:Gem::Version
146
+ version: '0'
147
+ required_rubygems_version: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - ">="
150
+ - !ruby/object:Gem::Version
151
+ version: '0'
152
+ requirements: []
153
+ rubyforge_project:
154
+ rubygems_version: 2.4.6
155
+ signing_key:
156
+ specification_version: 4
157
+ summary: Client lib to use attache server
158
+ test_files: []