For the last few years I've been helping Emily develop her wedding photography business - doing the website, doing the graphics for the brand, helping sort out backups and online tools to make things work efficiently. She's got very successful all of a sudden and was on Channel 5's How to Take Stunning Pictures show at the end of last year.
I've also been behind the lens at a fair few weddings as 'second shooter', and Emily convinced me I should put up a portfolio of some of my work. It's not something I've really talked about online, just something I've been doing on the side, but when I went back through the thousands (literally - I often take 20-30GB of photos at a wedding) of images I thought I should pull a few out and put them online.
I spent a sleepy Sunday putting a site together, and I got a beta online in about 6 hours (I'll get a separate domain name for it once I can think of one):
View the site
My wedding photography portfolio.
This is what it looks like:
My spec
I spent a long time looking around for a gallery system that did what I wanted. Here was my spec:
- I don't want to host any images. Someone else can handle that.
- I want to be able to upload a folder of images and get their URLS back as JSON.
- I want the hosting service to resize the images automatically to the same dimensions - about 1000 pixels on one side. I don't want to write code for this.
- I don't want any Flash.
- I want the images to appear full-screen, with no cluttery interface around the place.
- I want each image to have its own URL on my web page.
- I want to be able to scroll through the images using left/right clicking, arrow keys, space bar and mouse gestures.
- It's got to look slick - nice big images, nice fade transitions.
- No ads.
- No licence fees to pay for the slideshow.
- No thumbnails - I'm not bothered about them at the moment.
- I want to work in HTML5 and Javascript with not very much server side.
- If there's any server side it needs to be Ruby, not PHP or Coldfusion, etc.
- I'd like JQuery because I know it.
- I'd like the images to preload ad you browse through the gallery, so it feels nippy.
- I don't want all the images to load at once on the home page.
- It should look great on a small device as well as on the desktop, without requiring another website.
I looked around and found hundreds of articles on "Top 25 Jquery Image Galleries", and I tried a fair few out.
No luck with the existing galleries
Sorry to say, but I couldn't find anything that fitted my spec. They were either too complicated, too clicky-buttony, too ugly or too confusing.
So I threw out my initial testing code and started from scratch.
Image storage
I chose 23hq because it has a very simple API (none of the usual OAuth token dance here). You upload a bunch of photos to it, and then you can get 500 images back with one request as XML.
Ruby
Some server-side was required, so here's my Ruby code.
require 'open-uri'
require 'nokogiri'
require 'dalli'
# set ENV["TWENTY_THREE_HQ_USERNAME"], ENV["TWENTY_THREE_HQ_USERID"] and ENV["TWENTY_THREE_HQ_PASSWORD"]
class TwentyThreeHQ
def self.photos
d = Dalli::Client.new
p = d.get("photos")
return p unless p.nil?
r = Nokogiri::XML(open("http://www.23hq.com/services/rest/?api_key=mydemo&method=people.getPublicPhotos&user_id=#{ENV["TWENTY_THREE_HQ_USERID"]}&per_page=500&extras=url_o&username=#{ENV["TWENTY_THREE_HQ_USERNAME"]}&password=#{ENV["TWENTY_THREE_HQ_PASSWORD"]}")).css("photo").collect{|a| "http://www.23hq.com/23666/#{a.attributes["id"]}_#{a.attributes["secret"]}_large1k.jpg" }
d.set("photos",r)
r
end
end
So, what's this doing?
I use Heroku for my hosting, and it comes with a free Memcache server. This means you can store arbitrary stuff in a key value store that gets reset when you restart or redeploy the server. What I didn't want is to be hitting 23hq for a set of data that wouldn't change that often. So it stores an array of the photo URLS from the XML returned by 23HQ and returns it without having to hit the API every page load.
Serving the JSON
The next part is to serve this via JSON. I use Sinatra:
$:.unshift(File.expand_path(File.join(File.dirname(__FILE__),"lib")))
require 'sinatra'
require 'sinatra/jsonp'
require '23hq'
get '/' do
@photos = TwentyThreeHQ.photos.shuffle
haml :home
end
get '/photos' do
content_type :json
jsonp TwentyThreeHQ.photos.shuffle
end
So, after saving my class as lib/23hq I now I have two routes - a home page and a /photos route. The home page will be my gallery, and /photos will give me the URLs of the photos to display as JSON (or JSONP if I want it later).
Display the images
This is all one HTML5 page with some javascript. Here's what's in the toolbox:
- I used Backbone.js, which gives me a Controller for the #!/photos/12345 routes. This means photos are bookmarkable, and navigation is performed by changing the hash in the URL.]
- Underscore.js gives me access to some useful array and collection sorting utilities.
- One top of that JQuery for animations and transitions, as well as a couple of plugins for mouse gestures and resizing images to the full available space.
Javascript
The resulting code:
// Array Remove - By John Resig (MIT Licensed)
Array.prototype.remove = function(from, to) {
var rest = this.slice((to || from) + 1 || this.length);
this.length = from < 0 ? this.length + from : from;
return this.push.apply(this, rest);
};
var Photos, Pages;
$(document).ready(function() {
Photos = {
urls: [],
element: $("#photos"),
preload_element: $("#preload"),
frame: 0,
img: null,
animating: false,
setup: function(data) {
this.urls = data;
this.element.css("text-align","center")
this.img = $("").css("opacity",0).css("margin","auto").css("display","block");
this.element.append(this.img);
this.preload_element.css("display", "none");
$(".pager").bind('mousewheel', function(event, delta) {
Photos.scroll(delta);
});
$("#left").bind('click', function(event) {
Photos.prev();
});
$("#left").bind('mousemove', function(event) {
Photos.prevIndicator();
});
$("#right").bind('mousemove', function(event) {
Photos.nextIndicator();
});
$("#right").bind('click', function(event) {
Photos.next();
});
$("nav").bind('mouseenter', function(event) {
$(this).stop().animate({opacity: 0.9});
});
$("nav").bind('mouseleave', function(event) {
$(this).stop().animate({opacity: 0.5});
});
$(document).bind('keyup',function(event) {
switch(event.which) {
case 8: // backspace
case 33: // page up
case 37: // left
case 38: // up
Photos.prev();
event.stopPropagation();
break;
case 32: // space
case 34: // page down
case 39: // right
case 40: // down
Photos.next();
event.stopPropagation();
break;
}
});
this.element.disableTextSelect();
this.animate();
},
redirect: function(num) {
parent.location.hash = '#!/images/' + this.urls[num].split("/").pop().split("_")[0];
$(".page").animate({opacity: 0},250);
this.animate();
},
goto: function(id) {
var image_id = id;
var image_url = _.detect(this.urls, function(url) { return(url.indexOf(image_id + "_") != -1); });
this.frame = this.urls.indexOf(image_url);
this.redirect(this.frame);
},
next: function() {
if(this.frame < this.urls.length-1) {
this.preload(1);
this.frame = this.frame + 1;
this.redirect(this.frame);
}
else
{
this.first();
}
},
prev: function() {
if(this.frame > 0) {
this.preload(-1);
this.frame = this.frame - 1;
this.redirect(this.frame);
}
else {
this.last();
}
},
last: function() {
this.frame = this.urls.length-1;
this.redirect(this.frame);
},
first: function() {
this.frame = 0;
this.redirect(this.frame);
},
preload: function(offset) {
i = $("").attr("src", this.urls[this.frame+offset]);
this.preload_element.append(i);
},
scroll: function(delta) {
if(!this.animating) {
if(delta < 0) {
Photos.next();
}
if(delta > 0) {
Photos.prev();
}
}
} ,
nextIndicator: function() {
$("#next_indicator").stop().animate({opacity: 0.75},250, function(){ $(this).animate({opacity: 0},5000)});
},
prevIndicator: function() {
$("#prev_indicator").stop().animate({opacity: 0.75},250, function(){ $(this).animate({opacity: 0},5000)});
},
resize: function() {
this.img.aeImageResize({ height: $(window).height() - $("nav").height(), width: $(window).width() });
},
animate: function() {
this.animating = true;
this.img.stop(true)
.animate({opacity: 0}, 250,
function() {
$(this).attr("src", Photos.urls[Photos.frame])
.aeImageResize({ height: $(window).height() - $("nav").height(), width: $(window).width() })
.error(function() { console.log("rescuing from 404"); Photos.urls.remove(Photos.frame); Photos.animate(); })
.load(function() { $(this).animate({opacity: 1},250, function() { Photos.animating = false; })
});
});
}
};
$(window).resize(function() {
Photos.resize();
});
Pages = Backbone.Controller.extend({
routes: {
"!/home": "home",
"!/about": "about",
"!/pricing": "pricing",
"!/where": "where",
"!/contact": "contact",
"!/philosophy": "philosophy",
"!/images/:id": "image",
},
image: function(id) {
var i = Photos.goto(id);
},
hide: function(callback) {
$(".page").animate({opacity: 0},250, callback).css({display: "none", left: -20000});
},
swap: function(page_id) {
var p = page_id;
$("nav ul li a").removeClass("active");
$(page_id + "_show").addClass("active");
this.hide( function() {
$(p).css({display: "block", left: "50%"}).animate({opacity: 1}, 250);
});
},
home: function() {
$("nav ul li a").removeClass("active");
this.hide();
},
about: function() {
this.swap("#about");
},
pricing: function() {
this.swap("#pricing");
},
contact: function() {
this.swap("#contact");
},
where: function() {
this.swap("#where");
},
philosophy: function() {
this.swap("#philosophy");
}
});
$.getJSON("/photos",
function(data) {
Photos.setup(data);
Backbone.history.start();
});
new Pages();
});
I'm sure there are bugs in there, but this gives you a page with a clickable menu that shows overlaid pages of content over a clickable slideshow of images drawn from the /photos JSON.
HTML5 and HAML
I use HAML for all of my HTML wrangling. It's much harder to do invalid HTML output using it, and it's very readable. Here's the main layout:
!!! 5
%html
%head
%meta{:content => "text/html; charset=utf-8", "http-equiv" => "Content-Type"}/
%meta{:name=>"viewport",:content=>"width=device-width; initial-scale=1.0; maximum-scale=1.0; user-scalable=0;"}/
%meta{:name=>"apple-mobile-web-app-capable",:content=>"yes"}/
%meta{:name=>"apple-mobile-web-app-status-bar-style", :content=>"black-translucent"}/
%title Stef Lewandowski Photography
\
%script{:src => "https://ajax.googleapis.com/ajax/libs/jquery/1.4.4/jquery.min.js"}
%script{:src => "http://ajax.googleapis.com/ajax/libs/jqueryui/1.8/jquery-ui.min.js"}
%script{:src => "/js/jquery.easing.1.3.js", :type => "text/javascript"}
%script{:src => "/js/jquery.mousewheel.min.js", :type => "text/javascript"}
%script{:src => "/js/jquery.disableselect.js", :type => "text/javascript"}
%script{:src => "/js/underscore-min.js", :type => "text/javascript"}
%script{:src => "/js/backbone-min.js", :type => "text/javascript"}
%script{:src => "/js/jquery.ae.image.resize.min.js", :type => "text/javascript"}
%link{:href=>'http://fonts.googleapis.com/css?family=Raleway:100', :rel=>'stylesheet', :type=>'text/css'}/
%link{:href=>"/stylesheets/screen.css", :media=>"screen, projection", :rel=>"stylesheet", :type=>"text/css"}/
%link{:href=>"/stylesheets/print.css", :media=>"print", :rel=>"stylesheet", :type=>"text/css"}/
\
\
%link{:media=>"only screen and (max-device-width: 480px)", :rel=>"stylesheet", :type=>"text/css", :href=>"/stylesheets/iphone.css"}/
\
%body
%nav#main_navigation
%h1
%a{:href=>"#!/home"}
Stef Lewandowski
%h2
Wedding photography
%ul
%li
%a#about_show{:href=>"#!/about"}
About
%li
%a#philosophy_show{:href=>"#!/philosophy"}
Philosophy
%li
%a#pricing_show{:href=>"#!/pricing"}
Pricing
%li
%a#where_show{:href=>"#!/where"}
Where?
%li
%a#contact_show{:href=>"#!/contact"}
Contact
#photos
#preload
#left.pager
#prev_indicator
#right.pager
#next_indicator
#where.page
%h1
Where
%p
Stef has a home in Harpenden, Hertfordshire near the Bedfordshire border, and works in Central London at Leicester Square tube.
%p
If you'd like to meet up for a chat, these two locations are a good starting point.
%p
He is available for weddings in the London and the South East of England.
%p
Thinking further afield? Please
= succeed(".") do
%a{:href=>"#!/contact"}= "get in touch"
#pricing.page
%h1
Simple pricing
%h2
6 hours of photography
%br
A set of beautiful digital images
%br
£1500
%ul
%li
Your date is secured once a 20% deposit is paid, with the balance due the week before the wedding.
%li
I upload a selection of images from the day to your own online photo storage account, and/or give you a disk.
%li
Images are copyright free, so you are free to do with them as you please.
%li
Additional hours charged at £150 per hour.
%li
1 hour's travel from London is included, additional travel charged at 40p/mile.
%li
Post-production, prints, cards, framing, canvas, albums, DVDs, photo books and lots more as additional options through
= succeed(".") do
%a{:href=>"http://emilyquinton.com"}= "Emily Quinton Photography"
#contact.page
%h1
Yes! I'd like to hear from you
%p
The best way to reach me is by email or Twitter.
%h2
%a{:href=>"mailto:stef@emilyquinton.com"}
stef@emilyquinton.com
%h2
%a{:href=>"http://twitter.com/stef",:rel=>"me"}= "@Stef"
on Twitter
%p
For existing clients, you can also contact
%a{:href=>"http://emilyquinton.com"}="Emily Quinton"
who handles post-production and after care for special purchases.
#about.page
%h1
Award-winning creativity
%img{:src=>"/images/stef_square.png", :width=>100, :height=>100, :id=>"profile_photo"}
%p
Stef has won a clutch of awards for his creative work, and took to photography out of the love of making beautiful images.
%p{:style=>"clear: both;"}
When his parter, Emily Quinton's wedding business took off he found himself increasingly being involved as 'second shooter' at the larger weddings she was asked to photograph. It seemed to come naturally!
%p
Stef is represented by
%a{:href=>"http://emilyquinton.com"}
Emily Quinton Photography
and available for bookings in 2011.
#philosophy.page
%h1
Philosophy?
%br
It's all about capturing the spirit of the event.
%p
You'll hear people talking about 'reportage' and 'vintage' as what's currently in fashion, but the real thing that matters is that the feeling of the big day is captured for posterity.
%p
That's what Stef focusses on. Sometimes it's the little details that do it, sometimes it's an unexpected joke between friends, a teary eye or a look from father to daughter.
%p
Stef tries to be there, anticipating when these small, fleeting things happen and to open and close the camera shutter just in time.
%script{:src=> "/js/script.js", :type=>"text/javascript"}
The idea is that the page has all of the content in it, so I could have this as a static web application using a cache manifest if I wanted to.
Mobile and desktop
So, the site looked great in Safari on the OS X, but I also wanted to target handheld devices. You'll see in the code that if the device has a max size of 480px it gets a different stylesheet. Overriding my Compass SCSS sheet with an iphone one gives me a nice little app that works well in the browser and well in mobile Safari. Not so sure it looks good on a PC yet, but I'll try that out today.
Coffeescript?
Recently I've become a fan of Coffeescript, but I decided against it for this experiment. It would be easy to refactor this back into Coffeescript but for such a simple app it's probably not worth it.
Try it yourself
You can view and download all the code for this at https://github.com/stefl/stefoto