Photo Gallery is the second of three complete Rails applications in this book, each designed to demonstrate different real-world techniques for building Ajax applications in Rails, from start to finish. In Example A, the Review Quiz application was primarily textual. So this time, the focus will be more graphical. We'll look at an implementation of Ajax file upload, in-place-editing, encapsulating client-side behavior in custom JavaScript objects, and of course, RJS. The application is a simple photo gallery and is a simple way to organize and browse collections of images, as shown in Figure B-1. Ajax is used to make the uploading process smooth and to display full-size images inline with the thumbnails view. Figure 13-1. Gallery home pageTo download the source to this application, rails gallery, visit http://www.oreilly.com/catalog/9780596527440. Where files aren't listed they are the same as the Rails default skeleton. Once the files are in the correct place, you'll need to configure a database by editing config/database.yml. The default configuration as generated by rails gallery expects a MySQL database named gallery_development, accessible at localhost with the username root and no password. To get started, create a database for the application and change database.yml as needed, then run rake db:schema:load to create the application's database structure, as specified by schema.rb. The database and model for the application are very simple: just two tables and two models, for albums and photos. ActiveRecord::Schema.define( ) do create_table "albums", :force => true do |t| t.column "name", :string, :limit => 50, :default => "New Album", :null => false end create_table "photos", :force => true do |t| t.column "album_id", :integer, :default => 0, :null => false t.column "position", :integer, :default => 1, :null => false t.column "file", :binary, :default => "", :null => false t.column "width", :integer, :default => 0, :null => false, :limit => 50, t.column "height", :integer, :default => 0, :null => false, :limit => 50, t.column "name", :string, :default => "Untitled", :limit => 50, :null => false end add_index "photos", ["album_id"], :name => "album_id" add_index "photos", ["position"], :name => "position" end The Album model consists of nothing more than an association to the Photo model. class Album < ActiveRecord::Base has_many :photos, :order => "position", :dependent => :destroy end The first two methods in the Photo model handle saving an uploaded image (file=) and downloading it again (full). The next two methods (thumb and medium) generate scaled-down versions of the image using the RMagick library. class Photo < ActiveRecord::Base belongs_to :album acts_as_list :scope => :album def file= file with_image file.read do |img| self.width = img.columns self.height = img.rows write_attribute'file', img.to_blob end end def full( ) file end def thumb with_image do |image| geo = (1 > (height.to_f / width.to_f)) ? "x100" : "100" image = image.change_geometry(geo) do |cols, rows, img| img.resize!(cols, rows) end image = image.crop(Magick::CenterGravity, 100, 100) image.profile!('*', nil) return image.to_blob { self.format='JPG'; self.quality = 60 } end end def medium with_image do |img| maxw, maxh = 640, 480 new = maxw.to_f / maxh.to_f w, h = img.columns, img.rows old = w.to_f / h.to_f scaleratio = old > new ? maxw.to_f / w : maxh.to_f / h return img.resize(scaleratio).to_blob do self.format='JPG'; self.quality = 60 end end end private def with_image file=nil data = Base64.b64encode(file || self.file) img = Magick::Image::read_inline(data).first yield img img = nil GC.start end end routes.rb starts with an interesting trick, in service of the DRY principle: the first block loops over the three possible image sizes (full, thumb, and medium), and creates a route for each. The calls to map.resources set up RESTful routeseach one creating all of the needed routes to create, retrieve, update, and delete the given resources. ActionController::Routing::Routes.draw do |map| %w(full thumb medium).each do |size| map.named_route "#{size}_photo", "albums/:album_id/photos/:id.#{size}.jpg", :controller =>'photos', :action => size end map.resources :sessions map.resources :albums do |album| album.resources :photos end map.home '', :controller =>'albums' map.connect ':controller/:action/:id' end In environment.rb, the RMagick library is loaded to handle image manipulation, and we add text/jpeg to Rails' collection of known media types so we can handle JPEG images. At the bottom, some constants are defined to identify the administrator's credentials and the name of the site. RAILS_GEM_VERSION ='1.1.5' require File.join(File.dirname(__FILE_ _),'boot') Rails::Initializer.run do |config| end require'rmagick' require'base64' Mime::Type.register'image/jpeg', :jpg USERNAME, PASSWORD = "admin", "admin" SITE_TITLE = "Gallery" This file defines a filter for controlling access and a helper method to determine whether a user is logged in. The application doesn't have any real accounts; just a master user defined in environment.rb. class ApplicationController < ActionController::Base private # Before filter to protect administrator actions def require_login unless logged_in? redirect_to home_url return false end end # Login information is set in environment.rb def logged_in? session[:username] == USERNAME and session[:password] == PASSWORD end helper_method :logged_in? end application.rhtml is the master layout for the application. It provides sign-in and sign-out links; the call to yield lets the views insert their own content. <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en"> <head> <title><%= SITE_TITLE %></title> <%= stylesheet_link_tag "application" %> <%= javascript_include_tag :defaults %> </head> <body> <div > <% if logged_in? %> <%= link_to "Sign out", session_url(:id => session.session_id), :method => :delete %> <% else%> <%= link_to_function "Sign in", "$('signin').toggle();$('signin_link').toggle( )", :id => "signin_link" %> <%= form_tag sessions_url, :id => "signin", :style => "display: none" %> Username <%= text_field_tag'username' %> Password <%= text_field_tag'password' %> <%= submit_tag "Sign in"%> <%= end_form_tag %> <% end %> </div> <h1><%= link_to SITE_TITLE, home_url %></h1> <% if flash[:notice] %> <div ><%= flash[:notice] %></div> <% end %> <%= yield %> </body> </html> This file contains some helper methods. First, thumb_for takes a Photo instance and returns an HTML image tag with its thumbnail. Clicking the image triggers a JavaScript function defined with RJS syntax (page.photo.show). Even though RJS syntax is used, there's no client-server interactionit's just a way to use RJS to simplify your templates. The next method, toggle_edit_photo, is an RJS helper; it takes a photo ID and toggles the visibility three page elements. module ApplicationHelper def thumb_for photo url = thumb_photo_url(:album_id => photo.album_id, :id => photo) image = image_tag(url, :class => "thumb", :alt => "") link_to_function image, nil, :class => "show" do |page| page.photo.show medium_photo_url(:album_id => photo.album_id, :id => photo) end end def toggle_edit_photo id page.toggle "#{id}_name", "#{id}_edit", "#{id}_delete" end end The SessionsController provides actions for logging in (creating a session) and logging out (destroying a session). class SessionsController < ApplicationController def create session[:username] = params[:username] session[:password] = params[:password] flash[:notice] = "Couldn't authenticate you." unless logged_in? redirect_to :back end def destroy reset_session redirect_to :back end end The AlbumsController is a fairly typical Rails controller. The update action is the one Ajax part: it supports an in-place editing form by simply returning a piece of text to the browser (the new album name), rather than rendering a complete view. class AlbumsController < ApplicationController before_filter :require_login, :only => [:create,:update,:destroy ] before_filter :find_album, :only => [ :show, :update, :destroy ] def index @albums = Album.find :all end def create @album = Album.create params[:album] redirect_to album_url(:id => @album) end def show end def update @album.update_attributes params[:album] render :text => @album.name end def destroy @album.destroy redirect_to albums_url end private def find_album( ) @album = Album.find params[:id] end end The index.rhtml view loops through all the albums and displays each one, as was shown in Figure B-1. Only a user who is logged in can create, delete, or rename an album. <% if logged_in? %> <% form_for :album, Album.new, :url => albums_url, :html => { :id => "album_create" } do |f| %> <%= image_tag "add", :class =>'icon' %> <%= f.text_field :name %> <%= submit_tag "Create" %> <% end %> <% end %> <% if @albums.any? %> <ul > <% @albums.each do |album| %> <li> <%= link_to image_tag(thumb_photo_url(:album_id => album, :id => album.photos.first), :class => "thumb", :alt => ""), album_url(:id => album) %><br/> <%= link_to album.name, album_url(:id => album) %> <% if logged_in? %> <%= link_to image_tag("delete", :class =>'icon'), album_url(:id => album), :method => :delete %> <% end %><br/> <%= pluralize album.photos_count,'photo' %> </li>Figure B- <% end %> </ul> <% end %> The show.rhtml view provides the meat of the photo gallery's UI. For regular users, it presents all the album's photos, as shown in Figure B-2. Figure 13-2. Viewing an albumLogged-in users can edit albums in various ways. For example, the user can rename albums as shown in Figure B-3. Figure 13-3. Renaming an albumThe user can also edit an album by changing the photo's label, as shown in Figure B-4, and by adding a new photo. Figure 13-4. Editing an albumIf a user adds a new photo, show.rhtml provides the UI for selecting a photo to upload, as shown in Figure B-5, and also notifies the user that the upload is in progress, as shown in Figure B-6. Figure 13-5. Choosing a photo for uploadFigure 13-6. Uploading a photoBecause XMLHttpRequest can't handle file uploads, the photo upload form targets a hidden frame with an ID of uploader. The action that handles the upload, PhotosController#create, then renders a bare-bones HTML document with a JavaScript snippet to handle updating the page with the new photo. <h2 ><%= @album.name %></h2> <% if logged_in? %> <div style="display: none"> <%= javascript_tag "$('name').addClassName('rollover')" %> <%= javascript_tag "$('name').onclick=function( ){ $('name').toggle(); $('rename').toggle( ) }" %> <% remote_form_for :album, @album, :url => album_url, :html => { :method => :put }, :update =>'name', :before => "$('name').update('Saving...'); $('name').toggle( ); $('rename').toggle( )" do |f| %> <%= f.text_field :name %> <%= link_to_function "Cancel" do |page| page.toggle :name, :rename end %> <% end %> </div> <div > <% form_for :photo, Photo.new, :url => photos_url(:album_id => @album), :html => { :multipart => true, :target => "uploader", :id => "photo_upload" } do |f| %> <label for="photo_file"> <%= image_tag "add", :class =>'icon' %> Add a photo: </label> <%= f.file_field :file, :onchange => "Photo.upload( );" %> <% end %> <div style="display: none">Uploading...</div> <iframe src="/books/4/386/1/html/2//404.html" name="uploader"></iframe> </div> <% end %> <div ><%= render :partial => "photos/index" %></div> <%= render :partial => "photos/show" %> In this controller, the create action renders without a layout, because create.rhtml contains the necessary HTML boilerplate. A loop defines three methods at once, one for each image size (full, thumb, and medium). Rails' send_data method handles sending the JPEG data for the appropriately sized image. The update and destroy actions are fairly simple, but use RJS to send the results back to the page rather than do a full page update. class PhotosController < ApplicationController before_filter :require_login, :only => [:create,:update,:destroy ] before_filter :find_album before_filter :find_photo, :only => [ :update, :destroy ] def index render :partial => "index" end # Renders HTML containing a JavaScript callback to # finish the upload def create @photo = @album.photos.create params[:photo] render :layout =>'plain' end %w(full thumb medium).each do |size| class_eval <<-END def #{size} find_photo send_data @photo.#{size}, :filename => "\#{@photo.id}.#{size}.jpg", :type =>'image/jpeg', :disposition =>'inline' end caches_page :#{size} END end def update @photo.update_attributes :name => params[:name] render :update do |page| page["#{@photo.id}_name"].replace_html @photo.name end end def destroy @photo.destroy render :update do |page| page[:photos].update render(:partial => "index") end end private def find_album( ) @album = Album.find params[:album_id] end def find_photo( ) @photo = @album.photos.find params[:id] end end After uploading a new photo, create.rhtml is returned to the hidden frame containing a simple JavaScript instruction to add the new photo to the page. In order for the JavaScript to be evaluated by the frame, however, it must be wrapped in HTML boilerplate. The JavaScript itself delegates to Photo.finish, as defined in application.js. In order to access the parent document from the child frame, we use parent. <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en"> <head><title>Gallery</title></head> <body> <% url = photos_url :album_id => @album %> <%= javascript_tag "parent.Photo.finish('#{url}')" %> </body> </html> The _index.rhtml partial displays an unordered list of photos, one item per photo. Each entry in the list includes the thumbnail (using the thumb_for helper); if the user is logged in, the list also includes links to edit the image. For example, link_to_function photo.name displays the photo name as link; if you click, you get an inline form (defined by form_remote_tag). The toggle_edit_photo helper controls whether the photo or the form for editing is displayed. <% if @album.photos.any? %> <ul> <% @album.photos.each do |photo| %> <li > <%= thumb_for photo %><br/> <% if logged_in? %> <%= link_to_function photo.name, nil, :class => "rollover", :id => "#{photo.id}_name" do |page| page.toggle_edit_photo photo.id end %> <%= link_to_remote image_tag("delete", :class =>'icon', :id => "#{photo.id}_delete"), :url => photo_url(:album_id => @album, :id => photo), :method => :delete %> <%= form_remote_tag :url => photo_url(:album_id => @album, :id => photo), :html => { :style => "display: none", :method => :put, :id => "#{photo.id}_edit" }, :before => update_page { |page| page["#{photo.id}_name"].update'Saving...' page.toggle_edit_photo photo.id } %> <%= text_field_tag :name, photo.name %> <%= end_form_tag %> <% else %> <%= photo.name %> <% end %> </li> <% end %> </ul> <% end %> This partial simply displays a photo and provides links that invoke functions in application.js for navigating to the next and previous photo, as shown in Figure B-7. Figure 13-7. Viewing an individual photo<div style="display: none"></div> <div style="display: none;"> <img onclick="Photo.hide( );" src="/books/4/386/1/html/2/" /> <div > <%= link_to_function "#{image_tag'arrow_left'} Previous", "Photo.prev( )" %> <%= link_to_function "Next #{image_tag'arrow_right'}", "Photo.next( )" %> </div> </div> This library of JavaScript functions encapsulates the job of working with photos on the client side. Photo.upload uploads a file, displaying a "loading" message; Photo.finish adds a newly created photo to the page and hides the "loading" message; Photo.show requests the display of a particular photo; and so on. By organizing these methods into the Photo object, they can more easily be called from RJS. var Photo = { upload: function( ) { $('loading').show( ); $('photo_upload').submit( ); }, finish: function(url) { new Ajax.Updater('photos', url, {method:'get', onComplete:function( ){ $('loading').hide( ); $('photo_upload').reset( ); } }); }, show: function(url) { $('photo').src = url; $('mask').show( ); $('photo-wrapper').visualEffect('appear', {duration:0.5}); }, hide: function( ) { $('mask').hide( ); $('photo-wrapper').visualEffect('fade', {duration:0.5}); }, currentIndex: function( ) { return this.urls( ).indexOf($('photo').src); }, prev: function( ) { if(this.urls()[this.currentIndex( )-1]) { this.show(this.urls()[this.currentIndex( )-1]) } }, next: function( ) { if(this.urls()[this.currentIndex( )+1]) { this.show(this.urls()[this.currentIndex( )+1]) } }, urls: function( ) { if (!this.cached_urls) { this.cached_urls = $$('a.show').collect(function(el){ var onclick = el.onclick.toString( ); return onclick.match(/".*"/g)[0].replace(/"/g,''); }); } return this.cached_urls; } } As with Example A, the CSS stylesheet is included for completeness. There's one interesting UI trick here. The img.thumb rule adds a background image with the text "Loading..." to every photo thumbnail. The reason is that newly created images take a few seconds to generate thumbnails, and this satisfies the user's need to see something happening. Of course, when the image has loaded, it covers the default image. There's no interaction with the server, but it makes the application feel more dynamic and responsive. html { border-top: 10px solid #000; } body { background-color: #444; color: #fff; font-family: trebuchet ms; padding-top: 0px; padding-left: 50px; } h1 { text-shadow: black 1px 1px 5px; position: relative; left: -20px; width: 400px; } h2 { text-shadow: black 1px 1px 5px; } h2.rollover:hover { color: #ffc; } ul, ol, li { margin: 0; padding: 0; text-indent: 0; list-style-type: none; } li { float: left; margin-right: 20px; } a { color: #abc; text-decoration: none; } #utility { float: right; margin-right: 40px; color: #ddd; font-size: 0.8em; } #utility input { width: 80px; } #notice { background-color: #999; width: 500px; padding: 4px; margin-bottom: 10px; color: #900; } #album_create { background-color: #555; border: 1px solid #222; padding: 8px 12px; width: 300px; height: 34px; margin-bottom: 20px; } #album_create input { font-size: 1.2em; font-weight: bold; } #album_create input[type=text] { width: 200px; } #rename input { font-size: 1.5em; width: 250px; margin-left: -5px; background-color: #ffc; font-weight: bold; margin-top: -3px; } #upload_container { background-color: #555; border: 1px solid #222; padding: 0; width: 520px; height: 50px; margin-bottom: 20px; z-index: 1; } #uploader { width: 0px; height: 0px; border: 0px; } #photo_upload { position: relative; top: 15px; left: 20px; z-index: 2; } #loading { position: relative; top: -37px; left: 0; margin: 0px; padding-top: 10px; padding-bottom: -10px; font-size: 1.5em; height: 40px; width: 100%; text-align: center; background-color: #222; z-index: 3; opacity: .75; filter: alpha(opacity=75); } img.icon { position: relative; top: 3px; left: 2px; } img.thumb { border: 1px solid black; background: #C2C2C2 url(/images/loading.gif); width: 100px; height: 100px; } #mask { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background-color: #222; z-index: 1000; opacity: .75; filter: alpha(opacity=75); } #photo-wrapper { position: absolute; top: 0; left: 0; z-index: 1001; position: absolute; text-align: center; width: 100%; height: 100%; } #nav a { margin: 7px; color: #ccc; text-transform: uppercase; font-size: 0.7em; } #nav img { position: relative; top: 5px; } #photo { float: center; margin-top: 100px auto; margin-bottom: auto; border: 8px solid #222; } #albums li { width: 100px; text-align: center; font-size: 0.8em; color: #777; } #albums a { color: #fff; font-size: 1.2em; } #photos li .icon { left: 3px; } #photos li { width: 120px; height: 140px; text-align: center; font-size: 0.8em; xline-height: 0.8em; color: #ccc; margin-bottom: 10px; } #photos a { color: #fff; } #photos li input { width: 100px; background-color: #ffc; } #photos a.rollover:hover { color: #ffc; } |