diff --git a/.gitignore b/.gitignore index 4aa91a6..23c7dd6 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ /tmp *.swp + +/public/uploads/* diff --git a/Gemfile b/Gemfile index a8303f3..55cbe8d 100644 --- a/Gemfile +++ b/Gemfile @@ -40,3 +40,5 @@ gem 'jquery-rails' gem "redcarpet" gem "simple_form" gem 'database_cleaner' +gem "mini_magick" +gem 'carrierwave-mongoid' diff --git a/Gemfile.lock b/Gemfile.lock index 4c0fbcf..6f02b4b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -33,6 +33,12 @@ GEM bson_ext (1.6.4) bson (~> 1.6.4) builder (3.0.0) + carrierwave (0.6.2) + activemodel (>= 3.2.0) + activesupport (>= 3.2.0) + carrierwave-mongoid (0.2.1) + carrierwave (~> 0.6.1) + mongoid (~> 2.1) coffee-rails (3.2.2) coffee-script (>= 2.2.0) railties (~> 3.2.0) @@ -57,6 +63,8 @@ GEM mime-types (~> 1.16) treetop (~> 1.4.8) mime-types (1.19) + mini_magick (3.4) + subexec (~> 0.2.1) mongo (1.6.2) bson (~> 1.6.2) mongoid (2.4.11) @@ -116,6 +124,7 @@ GEM hike (~> 1.2) rack (~> 1.0) tilt (~> 1.1, != 1.3.0) + subexec (0.2.2) thor (0.15.3) tilt (1.3.3) treetop (1.4.10) @@ -131,9 +140,11 @@ PLATFORMS DEPENDENCIES bson_ext + carrierwave-mongoid coffee-rails (~> 3.2.1) database_cleaner jquery-rails + mini_magick mongoid rails (= 3.2.6) redcarpet diff --git a/app/assets/images/rss.png b/app/assets/images/rss.png new file mode 100644 index 0000000..77eaa99 Binary files /dev/null and b/app/assets/images/rss.png differ diff --git a/app/assets/javascripts/admin/posts.js.coffee b/app/assets/javascripts/admin/posts.js.coffee index d63051a..8308138 100644 --- a/app/assets/javascripts/admin/posts.js.coffee +++ b/app/assets/javascripts/admin/posts.js.coffee @@ -23,3 +23,14 @@ $(document).ready -> $.post $(this).attr('url'), text: content.val(), (data)-> preview.html(data) + $('a#upload_photo').click -> + $('input[type=file]').show().focus().click().hide() + + opt = + type: 'POST' + url: "/photos" + success: (data,status,xhr)-> + insertAtCaret('post_content', data) + + + $('input[type=file]').fileUpload opt diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 9097d83..077f5c5 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -12,4 +12,5 @@ // //= require jquery //= require jquery_ujs +//= require 'jquery.html5-fileupload' //= require_tree . diff --git a/app/assets/javascripts/home.js.coffee b/app/assets/javascripts/home.js.coffee new file mode 100644 index 0000000..7615679 --- /dev/null +++ b/app/assets/javascripts/home.js.coffee @@ -0,0 +1,3 @@ +# Place all the behaviors and hooks related to the matching controller here. +# All this logic will automatically be available in application.js. +# You can use CoffeeScript in this file: http://jashkenas.github.com/coffee-script/ diff --git a/app/assets/javascripts/insert.js b/app/assets/javascripts/insert.js new file mode 100644 index 0000000..209ff8b --- /dev/null +++ b/app/assets/javascripts/insert.js @@ -0,0 +1,34 @@ +function insertAtCaret(areaId,text) { + var txtarea = document.getElementById(areaId); + var scrollPos = txtarea.scrollTop; + var strPos = 0; + var br = ((txtarea.selectionStart || txtarea.selectionStart == '0') ? + "ff" : (document.selection ? "ie" : false ) ); + if (br == "ie") { + txtarea.focus(); + var range = document.selection.createRange(); + range.moveStart ('character', -txtarea.value.length); + strPos = range.text.length; + } + else if (br == "ff") strPos = txtarea.selectionStart; + + var front = (txtarea.value).substring(0,strPos); + var back = (txtarea.value).substring(strPos,txtarea.value.length); + txtarea.value=front+text+back; + strPos = strPos + text.length; + if (br == "ie") { + txtarea.focus(); + var range = document.selection.createRange(); + range.moveStart ('character', -txtarea.value.length); + range.moveStart ('character', strPos); + range.moveEnd ('character', 0); + range.select(); + } + else if (br == "ff") { + txtarea.selectionStart = strPos; + txtarea.selectionEnd = strPos; + txtarea.focus(); + } + txtarea.scrollTop = scrollPos; +} + diff --git a/app/assets/javascripts/photos.js.coffee b/app/assets/javascripts/photos.js.coffee new file mode 100644 index 0000000..7615679 --- /dev/null +++ b/app/assets/javascripts/photos.js.coffee @@ -0,0 +1,3 @@ +# Place all the behaviors and hooks related to the matching controller here. +# All this logic will automatically be available in application.js. +# You can use CoffeeScript in this file: http://jashkenas.github.com/coffee-script/ diff --git a/app/assets/stylesheets/admin/posts.css.scss b/app/assets/stylesheets/admin/posts.css.scss index 8470b37..246a86a 100644 --- a/app/assets/stylesheets/admin/posts.css.scss +++ b/app/assets/stylesheets/admin/posts.css.scss @@ -59,3 +59,6 @@ div.preview { display: none; } +#upload_photo { + float: right; +} diff --git a/app/assets/stylesheets/blogs.css.scss b/app/assets/stylesheets/blogs.css.scss index 8c4f941..e0e078d 100644 --- a/app/assets/stylesheets/blogs.css.scss +++ b/app/assets/stylesheets/blogs.css.scss @@ -2,6 +2,11 @@ // They will automatically be included in application.css. // You can use Sass (SCSS) here: http://sass-lang.com/ +div.blogs { + width: 700px; + float: left; +} + div.blog { background: url('bg_fn_blog_corner.png') no-repeat; padding: 2em 19px; @@ -20,7 +25,6 @@ div.blog { } strong { font-size: 10px; - line-height: 40px; margin-left: 1em; display: none; } diff --git a/app/assets/stylesheets/home.css.scss b/app/assets/stylesheets/home.css.scss new file mode 100644 index 0000000..f0ddc68 --- /dev/null +++ b/app/assets/stylesheets/home.css.scss @@ -0,0 +1,3 @@ +// Place all the styles related to the home controller here. +// They will automatically be included in application.css. +// You can use Sass (SCSS) here: http://sass-lang.com/ diff --git a/app/assets/stylesheets/photos.css.scss b/app/assets/stylesheets/photos.css.scss new file mode 100644 index 0000000..1a3e082 --- /dev/null +++ b/app/assets/stylesheets/photos.css.scss @@ -0,0 +1,3 @@ +// Place all the styles related to the photos controller here. +// They will automatically be included in application.css. +// You can use Sass (SCSS) here: http://sass-lang.com/ diff --git a/app/assets/stylesheets/slide.css.scss b/app/assets/stylesheets/slide.css.scss new file mode 100644 index 0000000..0adb1db --- /dev/null +++ b/app/assets/stylesheets/slide.css.scss @@ -0,0 +1,25 @@ +div.slide { + display: block; + float: left; + width: 400px; + .subscribe { + margin-bottom: 19px; + padding: 9px 0 16px 9px; + border: 1px solid #CCC; + border-radius: 3px; + background-color: #E9F2F5; + clear: both; + .subscribe_descrip { + width: 290px; + display: inline-block; + line-height: 20px; + font-size: 16px; + } + a { + img { + float: right; + margin-right: 20px; + } + } + } +} diff --git a/app/controllers/admin/posts_controller.rb b/app/controllers/admin/posts_controller.rb index 65e1143..8d46f67 100644 --- a/app/controllers/admin/posts_controller.rb +++ b/app/controllers/admin/posts_controller.rb @@ -28,7 +28,8 @@ class Admin::PostsController < ApplicationController def preview text = params[:text] || "" - md = Redcarpet::Markdown.new(Redcarpet::Render::HTML, :autolink=>true) + rd = Redcarpet::Render::HTML.new(:hard_wrap=>true) + md = Redcarpet::Markdown.new(rd, :autolink=>true) render :text => md.render(text) end end diff --git a/app/controllers/blogs_controller.rb b/app/controllers/blogs_controller.rb index eb5d7ce..4cc9505 100644 --- a/app/controllers/blogs_controller.rb +++ b/app/controllers/blogs_controller.rb @@ -9,6 +9,12 @@ class BlogsController < ApplicationController end end + def rss + @posts = Post.all.limit(10) + render :layout=>false + response.headers["Content-Type"] = "application/xml; charset=utf-8" + end + def show @post = Post.find(params[:id]) end diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb new file mode 100644 index 0000000..95f2992 --- /dev/null +++ b/app/controllers/home_controller.rb @@ -0,0 +1,4 @@ +class HomeController < ApplicationController + def index + end +end diff --git a/app/controllers/photos_controller.rb b/app/controllers/photos_controller.rb new file mode 100644 index 0000000..f5be7f7 --- /dev/null +++ b/app/controllers/photos_controller.rb @@ -0,0 +1,12 @@ +class PhotosController < ApplicationController + def create + @photo = Photo.new + @photo.image = params["Filedata"] + @photo.save! + render :text=> md_url(@photo.image.url) + end + + def md_url(url) + "![](#{url})" + end +end diff --git a/app/helpers/home_helper.rb b/app/helpers/home_helper.rb new file mode 100644 index 0000000..23de56a --- /dev/null +++ b/app/helpers/home_helper.rb @@ -0,0 +1,2 @@ +module HomeHelper +end diff --git a/app/helpers/photos_helper.rb b/app/helpers/photos_helper.rb new file mode 100644 index 0000000..0a10d47 --- /dev/null +++ b/app/helpers/photos_helper.rb @@ -0,0 +1,2 @@ +module PhotosHelper +end diff --git a/app/models/comment.rb b/app/models/comment.rb new file mode 100644 index 0000000..4915691 --- /dev/null +++ b/app/models/comment.rb @@ -0,0 +1,6 @@ +class Comment + include Mongoid::Document + include Mongoid::Timestamps + field :content, :type => String + validates :content, presence: true +end diff --git a/app/models/photo.rb b/app/models/photo.rb new file mode 100644 index 0000000..c4c8268 --- /dev/null +++ b/app/models/photo.rb @@ -0,0 +1,9 @@ +class Photo + include Mongoid::Document + include Mongoid::Timestamps + field :image + + attr_accessible :image + + mount_uploader :image, PhotoUploader +end diff --git a/app/models/post.rb b/app/models/post.rb index 33c8220..09325a6 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -14,4 +14,10 @@ class Post validates :title, :presence=>true, :uniqueness=> true validates :content, :presence=>true, :length => { :minimum=> 30 } validates :type, :presence=>true, :inclusion => { :in => [ TECH, LIFE, CREATOR ] } + + def content_html + rd = Redcarpet::Render::HTML.new(:hard_wrap=>true) + md = Redcarpet::Markdown.new(rd, :autolink=>true) + md.render(self.content) + end end diff --git a/app/uploaders/photo_uploader.rb b/app/uploaders/photo_uploader.rb new file mode 100644 index 0000000..5288bd7 --- /dev/null +++ b/app/uploaders/photo_uploader.rb @@ -0,0 +1,55 @@ +# encoding: utf-8 + +class PhotoUploader < CarrierWave::Uploader::Base + + # Include RMagick or MiniMagick support: + # include CarrierWave::RMagick + include CarrierWave::MiniMagick + + # Include the Sprockets helpers for Rails 3.1+ asset pipeline compatibility: + # include Sprockets::Helpers::RailsHelper + # include Sprockets::Helpers::IsolatedHelper + + # Choose what kind of storage to use for this uploader: + storage :file + # storage :fog + + # Override the directory where uploaded files will be stored. + # This is a sensible default for uploaders that are meant to be mounted: + def store_dir + "uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}" + end + + # Provide a default URL as a default if there hasn't been a file uploaded: + # def default_url + # # For Rails 3.1+ asset pipeline compatibility: + # # asset_path("fallback/" + [version_name, "default.png"].compact.join('_')) + # + # "/images/fallback/" + [version_name, "default.png"].compact.join('_') + # end + + # Process files as they are uploaded: + process :resize_to_limit => [680,nil] + # + # def scale(width, height) + # # do something + # end + + # Create different versions of your uploaded files: + # version :thumb do + # process :scale => [50, 50] + # end + + # Add a white list of extensions which are allowed to be uploaded. + # For images you might use something like this: + def extension_white_list + %w(jpg jpeg gif png) + end + + # Override the filename of the uploaded files: + # Avoid using model.id or version_name here, see uploader/store.rb for details. + # def filename + # "something.jpg" if original_filename + # end + +end diff --git a/app/views/admin/posts/_form.html.erb b/app/views/admin/posts/_form.html.erb index 6cd20c4..7067f1c 100644 --- a/app/views/admin/posts/_form.html.erb +++ b/app/views/admin/posts/_form.html.erb @@ -4,6 +4,8 @@
  • content
  • preview
  • + <%= link_to t(:upload_photo), "#", :id=>'upload_photo' %> + <%= f.input :content, :as=> :text, :label=>false %>
    <%= f.input :type, :as=>:select, :collection=> [ Post::TECH, Post::LIFE, Post::CREATOR ], :include_blank=>false %> diff --git a/app/views/blogs/_post.html.erb b/app/views/blogs/_post.html.erb index 22e598a..1fd47f2 100644 --- a/app/views/blogs/_post.html.erb +++ b/app/views/blogs/_post.html.erb @@ -1,5 +1,5 @@

    <%= link_to (post.title + " >>").html_safe, blog_path(post) %>

    -
    <%= post.content %>
    +
    <%= post.content_html.html_safe %>
    diff --git a/app/views/blogs/index.html.erb b/app/views/blogs/index.html.erb index 2d96a76..2993306 100644 --- a/app/views/blogs/index.html.erb +++ b/app/views/blogs/index.html.erb @@ -1 +1,3 @@ -<%= render :partial=> "post", :collection=> @posts %> +
    + <%= render :partial=> "post", :collection=> @posts %> +
    diff --git a/app/views/blogs/rss.builder b/app/views/blogs/rss.builder new file mode 100644 index 0000000..52fdd86 --- /dev/null +++ b/app/views/blogs/rss.builder @@ -0,0 +1,20 @@ +xml.instruct! + +xml.rss "version" => "2.0", "xmlns:dc" => "http://purl.org/dc/elements/1.1/" do + xml.channel do + + xml.title "windy's blog" + xml.link url_for :only_path => false, :controller => 'blogs' + xml.description "windy's blogs here" + + @posts.each do |post| + xml.item do + xml.title post.title + xml.link blog_url(post) + xml.description post.content + xml.guid blog_url(post) + end + end + + end +end diff --git a/app/views/blogs/show.html.erb b/app/views/blogs/show.html.erb index ef516c2..e7f4327 100644 --- a/app/views/blogs/show.html.erb +++ b/app/views/blogs/show.html.erb @@ -1,4 +1 @@ -
    -

    <%= @post.title %>

    -
    <%= @post.content %>
    -
    +<%= render :partial=> "post", :locals=> { :post=> @post } %> diff --git a/app/views/common/_slide.html.erb b/app/views/common/_slide.html.erb new file mode 100644 index 0000000..b2dc4ee --- /dev/null +++ b/app/views/common/_slide.html.erb @@ -0,0 +1,12 @@ +
    +
    + + <%= link_to(image_tag('rss.png'), rss_blogs_path) %> +
    +
    +
    +
    +
    + diff --git a/app/views/home/index.html.erb b/app/views/home/index.html.erb new file mode 100644 index 0000000..f36f32d --- /dev/null +++ b/app/views/home/index.html.erb @@ -0,0 +1,4 @@ +

    关于我

    +

    +为什么会有这里? +

    diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 745cb95..df580d3 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -19,10 +19,12 @@
  • 创业Creator
  • WinDy的个人中心 - 记录人生经历 - 技术 生活 And 创业 + 关于我
    <%= yield %> + <%= render "common/slide" %>
    diff --git a/app/views/photos/create.html.erb b/app/views/photos/create.html.erb new file mode 100644 index 0000000..266a501 --- /dev/null +++ b/app/views/photos/create.html.erb @@ -0,0 +1,2 @@ +

    Photos#create

    +

    Find me in app/views/photos/create.html.erb

    diff --git a/config/initializers/string_ext.rb b/config/initializers/string_ext.rb new file mode 100644 index 0000000..7b0ef2f --- /dev/null +++ b/config/initializers/string_ext.rb @@ -0,0 +1,42 @@ +require 'rexml/parsers/pullparser' + +class String + def truncate_html(len = 30, at_end = nil) + p = REXML::Parsers::PullParser.new(self) + tags = [] + new_len = len + results = '' + while p.has_next? && new_len > 0 + p_e = p.pull + case p_e.event_type + when :start_element + tags.push p_e[0] + results << "<#{tags.last}#{attrs_to_s(p_e[1])}>" + when :end_element + results << "" + when :text + results << p_e[0][0..new_len] + new_len -= p_e[0].length + else + results << "" + end + end + if at_end + results << "..." + end + tags.reverse.each do |tag| + results << "" + end + results + end + + private + + def attrs_to_s(attrs) + if attrs.empty? + '' + else + ' ' + attrs.to_a.map { |attr| %{#{attr[0]}="#{attr[1]}"} }.join(' ') + end + end +end diff --git a/config/locales/en.yml b/config/locales/en.yml index 179c14c..82bdde0 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -3,3 +3,4 @@ en: hello: "Hello world" + upload_photo: "上传图片" diff --git a/config/routes.rb b/config/routes.rb index 89f67e4..c0f7e04 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,6 +1,14 @@ WBlog::Application.routes.draw do root :to => 'blogs#index' - resources :blogs, :only=>[:index, :show] + resources :blogs, :only=>[:index, :show] do + collection do + get :rss + end + end + + # photos + resources :photos, :only=>[:create] + namespace :admin do resources :posts do collection do @@ -8,6 +16,7 @@ WBlog::Application.routes.draw do end end end + match '/about' => 'home#index' match '/admin' => 'admin/posts#new' match '/:type' => 'blogs#index' end diff --git a/spec/controllers/home_controller_spec.rb b/spec/controllers/home_controller_spec.rb new file mode 100644 index 0000000..403a70f --- /dev/null +++ b/spec/controllers/home_controller_spec.rb @@ -0,0 +1,12 @@ +require 'spec_helper' + +describe HomeController do + + describe "GET 'index'" do + it "returns http success" do + get 'index' + response.should be_success + end + end + +end diff --git a/spec/controllers/photos_controller_spec.rb b/spec/controllers/photos_controller_spec.rb new file mode 100644 index 0000000..5f2c12a --- /dev/null +++ b/spec/controllers/photos_controller_spec.rb @@ -0,0 +1,12 @@ +require 'spec_helper' + +describe PhotosController do + + describe "GET 'create'" do + it "returns http success" do + get 'create' + response.should be_success + end + end + +end diff --git a/spec/helpers/home_helper_spec.rb b/spec/helpers/home_helper_spec.rb new file mode 100644 index 0000000..4a37633 --- /dev/null +++ b/spec/helpers/home_helper_spec.rb @@ -0,0 +1,15 @@ +require 'spec_helper' + +# Specs in this file have access to a helper object that includes +# the HomeHelper. For example: +# +# describe HomeHelper do +# describe "string concat" do +# it "concats two strings with spaces" do +# helper.concat_strings("this","that").should == "this that" +# end +# end +# end +describe HomeHelper do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/helpers/photos_helper_spec.rb b/spec/helpers/photos_helper_spec.rb new file mode 100644 index 0000000..c4b4527 --- /dev/null +++ b/spec/helpers/photos_helper_spec.rb @@ -0,0 +1,15 @@ +require 'spec_helper' + +# Specs in this file have access to a helper object that includes +# the PhotosHelper. For example: +# +# describe PhotosHelper do +# describe "string concat" do +# it "concats two strings with spaces" do +# helper.concat_strings("this","that").should == "this that" +# end +# end +# end +describe PhotosHelper do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/models/comment_spec.rb b/spec/models/comment_spec.rb new file mode 100644 index 0000000..ee00a23 --- /dev/null +++ b/spec/models/comment_spec.rb @@ -0,0 +1,10 @@ +require 'spec_helper' + +describe Comment do + it "comment should not blank" do + a = Comment.new + a.save.should == false + a = Comment.new(content: '11') + a.save.should == true + end +end diff --git a/spec/models/photo_spec.rb b/spec/models/photo_spec.rb new file mode 100644 index 0000000..d64d07f --- /dev/null +++ b/spec/models/photo_spec.rb @@ -0,0 +1,8 @@ +require 'spec_helper' + +describe Photo do + it "photo with picture will be ok" do + a = Photo.new + a.save + end +end diff --git a/spec/views/home/index.html.erb_spec.rb b/spec/views/home/index.html.erb_spec.rb new file mode 100644 index 0000000..a7697f7 --- /dev/null +++ b/spec/views/home/index.html.erb_spec.rb @@ -0,0 +1,5 @@ +require 'spec_helper' + +describe "home/index.html.erb" do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/views/photos/create.html.erb_spec.rb b/spec/views/photos/create.html.erb_spec.rb new file mode 100644 index 0000000..fd05e9a --- /dev/null +++ b/spec/views/photos/create.html.erb_spec.rb @@ -0,0 +1,5 @@ +require 'spec_helper' + +describe "photos/create.html.erb" do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/vendor/assets/javascripts/jquery.html5-fileupload.js b/vendor/assets/javascripts/jquery.html5-fileupload.js new file mode 100644 index 0000000..43753b8 --- /dev/null +++ b/vendor/assets/javascripts/jquery.html5-fileupload.js @@ -0,0 +1,513 @@ +/* + * jQuery HTML5 File Upload + * + * Author: timdream at gmail.com + * Web: http://timc.idv.tw/html5-file-upload/ + * + * Ajax File Upload that use real xhr, + * built with getAsBinary, sendAsBinary, FormData, FileReader, ArrayBuffer, BlobBuilder and etc. + * works in Firefox 3, Chrome 5, Safari 5 and higher + * + * Image resizing and uploading currently works in Fx 3 and up, and Chrome 9 (dev) and up only. + * Extra settings will allow current Webkit users to upload the original image + * or send the resized image in base64 form. + * + * Usage: + * $.fileUploadSupported // a boolean value indicates if the browser is supported. + * $.imageUploadSupported // a boolean value indicates if the browser could resize image and upload in binary form. + * $.fileUploadAsBase64Supported // a boolean value indicate if the browser upload files in based64. + * $.imageUploadAsBase64Supported // a boolean value indicate if the browser could resize image and upload in based64. + * $('input[type=file]').fileUpload(ajaxSettings); //Make a input[type=file] select-and-send file upload widget + * $('#any-element').fileUpload(ajaxSettings); //Make a element receive dropped file + * //TBD $('form#fileupload').fileUpload(ajaxSettings); //Send a ajax form with file + * //TBD $('canvas').fileUpload(ajaxSettings); //Upload given canvas as if it's an png image. + * + * ajaxSettings is the object contains $.ajax settings that will be passed to. + * Available extended settings are: + * fileType: + * regexp check against filename extension; You should always checked it again on server-side. + * e.g. /^(gif|jpe?g|png|tiff?)$/i for images + * fileMaxSize: + * Maxium file size allowed in bytes. Use scientific notation for converience. + * e.g. 1E4 for 1KB, 1E8 for 1MB, 1E9 for 10MB. + * If you really care the difference between 1024 and 1000, use Math.pow(2, 10) + * fileError(info, textStatus, textDescription): + * callback function when there is any error preventing file upload to start, + * $.ajax and ajax events won't be called when error. + * Use $.noop to overwrite default alert function. + * imageMaxWidth, imageMaxHeight: + * Use any of the two settings to enable client-size image resizing. + * Image will be resized to fit into given rectangle. + * File size and type limit checking will be ignored. + * allowUploadOriginalImage: + * Set to true if you accept original image to be uploaded as a fallback + * when image resizing functionality is not availible (such as Webkit browsers). + * File size and type limit will be enforced. + * allowDataInBase64: + * Alternatively, you may wish to resize the image anyway and send the data + * in base64. The data will be 133% larger and you will need to process it further with + * server-side script. + * This setting might work with browsers which could read file but cannot send it in original + * binary (no known browser are designed this way though) + * forceResize: + * Set to true will cause the image being re-sampled even if the resized image + * has the same demension as the original one. + * imageType: + * Acceptable values are: 'jpeg', 'png', or 'auto'. + * + * TBD: + * ability to change settings after binding (you can unbind and bind again as a workaround) + * multipole file handling + * form intergation + * + */ + +(function($) { + // Don't do logging if window.log function does not exist. + var log = window.log || $.noop; + + // jQuery.ajax config + var config = { + fileError: function (info, textStatus, textDescription) { + window.alert(textDescription); + } + }; + + // Feature detection + + // Read as binary string: FileReader API || Gecko-specific function (Fx3) + var canReadAsBinaryString = (window.FileReader || window.File.prototype.getAsBinary); + // Read file using FormData interface + var canReadFormData = !!(window.FormData); + // Read file into data: URL: FileReader API || Gecko-specific function (Fx3) + var canReadAsBase64 = (window.FileReader || window.File.prototype.getAsDataURL); + + var canResizeImageToBase64 = !!(document.createElement('canvas').toDataURL); + var canResizeImageToBinaryString = canResizeImageToBase64 && window.atob; + var canResizeImageToFile = !!(document.createElement('canvas').mozGetAsFile); + + // Send file in multipart/form-data with binary xhr (Gecko-specific function) + // || xhr.send(blob) that sends blob made with ArrayBuffer. + var canSendBinaryString = ( + (window.XMLHttpRequest && window.XMLHttpRequest.prototype.sendAsBinary) + || (window.ArrayBuffer && window.BlobBuilder) + ); + // Send file as in FormData object + var canSendFormData = !!(window.FormData); + // Send image base64 data by extracting data: URL + var canSendImageInBase64 = !!(document.createElement('canvas').toDataURL); + + var isSupported = ( + (canReadAsBinaryString && canSendBinaryString) + || (canReadFormData && canSendFormData) + ); + var isImageSupported = ( + canReadAsBase64 && ( + (canResizeImageToBinaryString && canSendBinaryString) + || (canResizeImageToFile && canSendFormData) + ) + ); + var isSupportedInBase64 = canReadAsBase64; + var isImageSupportedInBase64 = canReadAsBase64 && canResizeImageToBase64; + + var dataURLtoBase64 = function (dataurl) { + return dataurl.substring(dataurl.indexOf(',')+1, dataurl.length); + } + + // Step 1: check file info and attempt to read the file + // paramaters: Ajax settings, File object + var handleFile = function (settings, file) { + var info = { + // properties of standard File object || Gecko 1.9 properties + type: file.type || '', // MIME type + size: file.size || file.fileSize, + name: file.name || file.fileName + }; + + settings.resizeImage = !!(settings.imageMaxWidth || settings.imageMaxHeight); + + if (settings.resizeImage && !isImageSupported && settings.allowUploadOriginalImage) { + log('WARN: Fall back to upload original un-resized image.'); + settings.resizeImage = false; + } + + if (settings.resizeImage) { + settings.imageMaxWidth = settings.imageMaxWidth || Infinity; + settings.imageMaxHeight = settings.imageMaxHeight || Infinity; + } + + if (!settings.resizeImage) { + if (settings.fileType && settings.fileType.test) { + // Not using MIME types + if (!settings.fileType.test(info.name.substr(info.name.lastIndexOf('.')+1))) { + log('ERROR: Invalid Filetype.'); + settings.fileError.call(this, info, 'INVALID_FILETYPE', 'Invalid filetype.'); + return; + } + } + + if (settings.fileMaxSize && file.size > settings.fileMaxSize) { + log('ERROR: File exceeds size limit.'); + settings.fileError.call(this, info, 'FILE_EXCEEDS_SIZE_LIMIT', 'File exceeds size limit.'); + return; + } + } + + if (!settings.resizeImage && canReadFormData) { + log('INFO: Bypass file reading, insert file object into FormData object directly.'); + handleForm(settings, 'file', file, info); + } else if (window.FileReader) { + log('INFO: Using FileReader to do asynchronously file reading.'); + var reader = new FileReader(); + reader.onerror = function (ev) { + if (ev.target.error) { + switch (ev.target.error) { + case 8: + log('ERROR: File not found.'); + settings.fileError.call(this, info, 'FILE_NOT_FOUND', 'File not found.'); + break; + case 24: + log('ERROR: File not readable.'); + settings.fileError.call(this, info, 'IO_ERROR', 'File not readable.'); + break; + case 18: + log('ERROR: File cannot be access due to security constrant.'); + settings.fileError.call(this, info, 'SECURITY_ERROR', 'File cannot be access due to security constrant.'); + break; + case 20: //User Abort + break; + } + } + } + if (!settings.resizeImage) { + if (canSendBinaryString) { + reader.onloadend = function (ev) { + var bin = ev.target.result; + handleForm(settings, 'bin', bin, info); + }; + reader.readAsBinaryString(file); + } else if (settings.allowDataInBase64) { + reader.onloadend = function (ev) { + handleForm( + settings, + 'base64', + dataURLtoBase64(ev.target.result), + info + ); + }; + reader.readAsDataURL(file); + } else { + log('ERROR: No available method to extract file; allowDataInBase64 not set.'); + settings.fileError.call(this, info, 'NO_BIN_SUPPORT_AND_BASE64_NOT_SET', 'No available method to extract file; allowDataInBase64 not set.'); + } + } else { + reader.onloadend = function (ev) { + var dataurl = ev.target.result; + handleImage(settings, dataurl, info); + }; + reader.readAsDataURL(file); + } + } else if (window.File.prototype.getAsBinary) { + log('WARN: FileReader does not exist, UI will be blocked when reading big file.'); + if (!settings.resizeImage) { + try { + var bin = file.getAsBinary(); + } catch (e) { + log('ERROR: File not readable.'); + settings.fileError.call(this, info, 'IO_ERROR', 'File not readable.'); + return; + } + handleForm(settings, 'bin', bin, info); + } else { + try { + var bin = file.getAsDataURL(); + } catch (e) { + log('ERROR: File not readable.'); + settings.fileError.call(this, info, 'IO_ERROR', 'File not readable.'); + return; + } + handleImage(settings, dataurl, info); + } + } else { + log('ERROR: No available method to extract file; this browser is not supported.'); + settings.fileError.call(this, info, 'NOT_SUPPORT', 'ERROR: No available method to extract file; this browser is not supported.'); + } + }; + + // step 1.5: inject file into , paste the pixels into , + // read the final image + var handleImage = function (settings, dataurl, info) { + var img = new Image(); + img.onerror = function () { + log('ERROR: failed to load, file is not a supported image format.'); + settings.fileError.call(this, info, 'FILE_NOT_IMAGE', 'File is not a supported image format.'); + }; + img.onload = function () { + var ratio = Math.max( + img.width/settings.imageMaxWidth, + img.height/settings.imageMaxHeight, + 1 + ); + var d = { + w: Math.floor(Math.max(img.width/ratio, 1)), + h: Math.floor(Math.max(img.height/ratio, 1)) + } + log( + 'INFO: Original image size: ' + img.width.toString(10) + 'x' + img.height.toString(10) + + ', resized image size: ' + d.w + 'x' + d.h + '.' + ); + if (!settings.forceResize && img.width === d.w && img.height === d.h) { + log('INFO: Image demension is the same, send the original file.'); + if (canResizeImageToBinaryString) { + handleForm( + settings, + 'bin', + window.atob(dataURLtoBase64(dataurl)), + info + ); + } else if (settings.allowDataInBase64) { + handleForm( + settings, + 'base64', + dataURLtoBase64(dataurl), + info + ); + } else { + log('ERROR: No available method to send the original file; allowDataInBase64 not set.'); + settings.fileError.call(this, info, 'NO_BIN_SUPPORT_AND_BASE64_NOT_SET', 'No available method to extract file; allowDataInBase64 not set.'); + } + return; + } + var canvas = document.createElement('canvas'); + canvas.setAttribute('width', d.w); + canvas.setAttribute('height', d.h); + canvas.getContext('2d').drawImage( + img, + 0, + 0, + img.width, + img.height, + 0, + 0, + d.w, + d.h + ); + if (!settings.imageType || settings.imageType === 'auto') { + if (info.type === 'image/jpeg') settings.imageType = 'jpeg'; + else settings.imageType = 'png'; + } + + var ninfo = { + type: 'image/' + settings.imageType, + name: info.name.substr(0, info.name.indexOf('.')) + '.resized.' + settings.imageType + }; + + if (canResizeImageToFile && canSendFormData) { + // Gecko 2 (Fx4) non-standard function + var nfile = canvas.mozGetAsFile( + ninfo.name, + 'image/' + settings.imageType + ); + ninfo.size = file.size || file.fileSize; + handleForm( + settings, + 'file', + nfile, + ninfo + ); + } else if (canResizeImageToBinaryString && canSendBinaryString) { + // Read the image as DataURL, convert it back to binary string. + var bin = window.atob(dataURLtoBase64(canvas.toDataURL('image/' + settings.imageType))); + ninfo.size = bin.length; + handleForm( + settings, + 'bin', + bin, + ninfo + ); + } else if (settings.allowDataInBase64 && canResizeImageToBase64 && canSendImageInBase64) { + handleForm( + settings, + 'base64', + dataURLtoBase64(canvas.toDataURL('image/' + settings.imageType)), + ninfo + ); + } else { + log('ERROR: No available method to extract image; allowDataInBase64 not set.'); + settings.fileError.call(this, info, 'NO_BIN_SUPPORT_AND_BASE64_NOT_SET', 'No available method to extract file; allowDataInBase64 not set.'); + } + } + img.src = dataurl; + } + // Step 2: construct form data and send the file + // paramaters: Ajax settings, File object, binary string of file || null, file info assoc array + var handleForm = function (settings, type, data, info) { + if (canSendFormData && type === 'file') { + // FormData API saves the day + log('INFO: Using FormData to construct form.'); + var formdata = new FormData(); + formdata.append('Filedata', data); + // Prevent jQuery form convert FormData object into string. + settings.processData = false; + // Prevent jQuery from overwrite automatically generated xhr content-Type header + // by unsetting the default contentType and inject data only right before xhr.send() + settings.contentType = null; + settings.__beforeSend = settings.beforeSend; + settings.beforeSend = function (xhr, s) { + s.data = formdata; + if (s.__beforeSend) return s.__beforeSend.call(this, xhr, s); + } + //settings.data = formdata; + } else if (canSendBinaryString && type === 'bin') { + log('INFO: Concat our own multipart/form-data data string.'); + + // A placeholder MIME type + if (!info.type) info.type = 'application/octet-stream'; + + if (/[^\x20-\x7E]/.test(info.name)) { + log('INFO: Filename contains non-ASCII code, do UTF8-binary string conversion.'); + info.name_bin = unescape(encodeURIComponent(info.name)); + } + + //filtered out non-ASCII chars in filenames + // info.name = info.name.replace(/[^\x20-\x7E]/g, '_'); + + // multipart/form-data boundary + var bd = 'xhrupload-' + parseInt(Math.random()*(2 << 16)); + settings.contentType = 'multipart/form-data; boundary=' + bd; + var formdata = '--' + bd + '\n' // RFC 1867 Format, simulate form file upload + + 'content-disposition: form-data; name="Filedata";' + + ' filename="' + (info.name_bin || info.name) + '"\n' + + 'Content-Type: ' + info.type + '\n\n' + + data + '\n\n' + + '--' + bd + '--'; + + if (window.XMLHttpRequest.prototype.sendAsBinary) { + // Use xhr.sendAsBinary that takes binary string + log('INFO: Pass binary string to xhr.'); + settings.data = formdata; + } else { + // make a blob + log('INFO: Convert binary string into Blob.'); + var buf = new ArrayBuffer(formdata.length); + var view = new Uint8Array(buf); + $.each( + formdata, + function (i, o) { + view[i] = o.charCodeAt(0); + } + ); + var bb = new BlobBuilder(); + bb.append(buf); + var blob = bb.getBlob(); + + settings.processData = false; + settings.__beforeSend = settings.beforeSend; + settings.beforeSend = function (xhr, s) { + s.data = blob; + if (s.__beforeSend) return s.__beforeSend.call(this, xhr, s); + }; + } + + } else if (settings.allowDataInBase64 && type === 'base64') { + log('INFO: Concat our own multipart/form-data data string; send the file in base64 because binary xhr is not supported.'); + + // A placeholder MIME type + if (!info.type) info.type = 'application/octet-stream'; + + // multipart/form-data boundary + var bd = 'xhrupload-' + parseInt(Math.random()*(2 << 16)); + settings.contentType = 'multipart/form-data; boundary=' + bd; + settings.data = '--' + bd + '\n' // RFC 1867 Format, simulate form file upload + + 'content-disposition: form-data; name="Filedata";' + + ' filename="' + encodeURIComponent(info.name) + '.base64"\n' + + 'Content-Transfer-Encoding: base64\n' // Vaild MIME header, but won't work with PHP file upload handling. + + 'Content-Type: ' + info.type + '\n\n' + + data + '\n\n' + + '--' + bd + '--'; + } else { + log('ERROR: Data is not given in processable form.'); + settings.fileError.call(this, info, 'INTERNAL_ERROR', 'Data is not given in processable form.'); + return; + } + xhrupload(settings); + }; + + // Step 3: start sending out file + var xhrupload = function (settings) { + log('INFO: Sending file.'); + if (typeof settings.data === 'string' && canSendBinaryString) { + log('INFO: Using xhr.sendAsBinary.'); + settings.___beforeSend = settings.beforeSend; + settings.beforeSend = function (xhr, s) { + xhr.send = xhr.sendAsBinary; + if (s.___beforeSend) return s.___beforeSend.call(this, xhr, s); + } + } + $.ajax(settings); + }; + + $.fn.fileUpload = function(settings) { + this.each(function(i, el) { + if ($(el).is('input[type=file]')) { + log('INFO: binding onchange event to a input[type=file].'); + $(el).bind( + 'change', + function () { + if (!this.files.length) { + log('ERROR: no file selected.'); + return; + } else if (this.files.length > 1) { + log('WARN: Multiple file upload not implemented yet, only first file will be uploaded.'); + } + handleFile($.extend({}, config, settings), this.files[0]); + + if (this.form.length === 1) { + this.form.reset(); + } else { + log('WARN: Unable to reset file selection, upload won\'t be triggered again if user selects the same file.'); + } + return; + } + ); + } + + if ($(el).is('form')) { + log('ERROR:
    not implemented yet.'); + } else { + log('INFO: binding ondrop event.'); + $(el).bind( + 'dragover', // dragover behavior should be blocked for drop to invoke. + function(ev) { + return false; + } + ).bind( + 'drop', + function (ev) { + if (!ev.originalEvent.dataTransfer.files) { + log('ERROR: No FileList object present; user might had dropped text.'); + return false; + } + if (!ev.originalEvent.dataTransfer.files.length) { + log('ERROR: User had dropped a virual file (e.g. "My Computer")'); + return false; + } + if (!ev.originalEvent.dataTransfer.files.length > 1) { + log('WARN: Multiple file upload not implemented yet, only first file will be uploaded.'); + } + handleFile($.extend({}, config, settings), ev.originalEvent.dataTransfer.files[0]); + return false; + } + ); + } + }); + + return this; + }; + + $.fileUploadSupported = isSupported; + $.imageUploadSupported = isImageSupported; + $.fileUploadAsBase64Supported = isSupportedInBase64; + $.imageUploadAsBase64Supported = isImageSupportedInBase64; + +})(jQuery);