attache-rails 0.4.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
+ 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: []