photo_folder.js

Demo Gallery | Source Code

PhotoFolder is an MIT licensed hackable photo gallery system built using jQuery View and jQuery Routes.

To create your own gallery:

  1. Download or:
    git clone git://github.com/syntacticx/photo_folder.git
  2. Put images in photos folder
  3. Edit photos.json or to auto generate photos.json:
    rake generate
  4. Enjoy!
PhotoFolder = (function(){
  var PhotoFolder = {
    routes: {
      '/': 'PhotoFolder.LayoutView#displayFirstPhoto',
      '/*': 'PhotoFolder.LayoutView#displayPhoto'
    },

Photo objects

    photos: [],

An index of the paths is kept for quick lookups of the photos array

    photoPaths: [],

Album objects

    albums: [],
    albumNames: [],
    lastPositionInAlbum: 0, //used during data load
    load: function(config,callback){
      PhotoFolder.config = $.extend({
        initialPath: '/',
        jsonLocation: 'photos.json',
        fadeLength: 333,
        fadeOutDelay: 50, //makes for a smoother looking transition
        bindKeyboardEvents: true,
        loadingTimeout: 1.25,
        useFilenamesAsTitles: false,
        setWindowTitle: true
      },config || {});
      $(document).ready(function(){
        $.getJSON(PhotoFolder.config.jsonLocation,function(photos){
          for(var i = 0; i < photos.length; ++i){
            new PhotoFolder.Photo(photos[i],i);
          }
          $.routes(PhotoFolder.routes);
          callback(PhotoFolder.LayoutView.instance());
          if(!$.routes("get") || $.routes("get") == ""){
            $.routes("set",PhotoFolder.config.initialPath);
          }
        });
      });
    }
  };

The Photo class

  PhotoFolder.Photo = function(src,position){
    this.src = src;
    this.position = position;
    var path_components = src.split('/');
    var file_name = path_components.pop();
    var album_name = path_components.pop();
    if($.inArray(album_name,PhotoFolder.albumNames) == -1){
      PhotoFolder.lastPositionInAlbum = 0;
      this.album = new PhotoFolder.Album(src);
      PhotoFolder.albums.push(this.album);
      PhotoFolder.albumNames.push(album_name);
    }else{
      this.album = PhotoFolder.albums[$.inArray(album_name,PhotoFolder.albumNames)];
    }
    if(PhotoFolder.config.useFilenamesAsTitles){

un camel case

      this.name = file_name.replace(/([a-z])([A-Z])/g,'$1 $2');

remove file extension

      this.name = this.name.replace(/\.(jpg|png)$/,'');

replace 01-IMG with IMG

      this.name = this.name.replace(/(\/|^)[\d]{1,4}\-/,'$1');

replace untitled images with album name + #

      if(this.name.toLowerCase().match(/(^(dcim|img)_|^[\d]+\.(png|jpg)$)/i)){
        this.name = album_name + ' #' + (PhotoFolder.lastPositionInAlbum + 1);
      }

replace dash and underscore

      this.name = this.name.replace(/[\-\_]+/g,' '); 
    }else{
      this.name = this.album.name + ' #' + (PhotoFolder.lastPositionInAlbum + 1);
    }
    this.path = this.album.path + '/' + (PhotoFolder.lastPositionInAlbum + 1);
    PhotoFolder.photoPaths.push(this.path);
    PhotoFolder.photos.push(this);
    ++PhotoFolder.lastPositionInAlbum;
  };
  
  PhotoFolder.Photo.preloadedImages = {};
  PhotoFolder.Photo.prototype.preload = function(callback){
    if(this.src in PhotoFolder.Photo.preloadedImages){
      if(callback){
        callback();
      }
    }else{
      var image = new Image();
      $(image).load($.proxy(function(){
        this.height = image.height;
        this.width = image.width;
        PhotoFolder.Photo.preloadedImages[this.src] = true;
        if(callback){
          callback();
        }
      },this));
      image.src = this.src;
    }
  };

  PhotoFolder.Album = function(src){
    var path_components = src.split('/');
    path_components.pop();
    this.name = path_components[path_components.length - 1];

un camel case

    this.name = this.name.replace(/([a-z])([A-Z])/g,'$1 $2');

replace dash and underscore

    this.name = this.name.replace(/[\-\_]+/g,' '); 
    this.parentName = path_components[path_components.length - 2];
    this.depth = path_components.length - 1;
    path_components.shift();
    this.path = '/' + path_components.join('/');
  };
  
  PhotoFolder.LayoutView = $.view(function(){
    this.queuedAction = false;
    this.lastAlbum = false;
    this.ready(this.resizeLinkElementsToFill);
    $(window).resize(this.resizeLinkElementsToFill);
    return this.div({className:'photo_folder'},
      PhotoFolder.AlbumListView.instance(),
      PhotoFolder.LoadingIndicatorView.instance(),
      this.nextLink = this.a({
        href: '#',
        title: 'Next (Use Right Arrow Key)',
        className:'next'
      }),
      this.previousLink = this.a({
        href: '#',
        title: 'Previous (Use Left Arrow Key)',
        className:'previous'
      }),
      this.photoElement = this.div()
    );
  },{

called from routing / link clicks

    displayFirstPhoto: function(){
      this.displayPhotoAtIndex(0);
    },

called from routing / link clicks

    displayPhoto: function(params){
      var path = ('/' + params.path).replace(/\/\//g,'/');
      var index = $.inArray(path,PhotoFolder.photoPaths);
      this.displayPhotoAtIndex(index);
    },

called from keyboard events

    displayNextPhoto: function(){
      this.displayPhoto({
        path: $(this.nextLink).attr('href').replace(/#/,'')
      });
    },

called from keyboard events

    displayPreviousPhoto: function(){
      this.displayPhoto({
        path: $(this.previousLink).attr('href').replace(/#/,'')
      });
    },

sets the current, next and previous indicies, and writes correct href attributes of navigation links

    setCurrentIndex: function(index){
      var next_index = this.getIndexAfter(index);
      var previous_index = this.getIndexBefore(index);
      $(this.nextLink).attr('href','#' + PhotoFolder.photoPaths[next_index]);
      $(this.previousLink).attr('href','#' + PhotoFolder.photoPaths[previous_index]);
      PhotoFolder.photos[index].preload();
      PhotoFolder.photos[next_index].preload();
      PhotoFolder.photos[previous_index].preload();
      PhotoFolder.photos[this.getIndexAfter(next_index)].preload();
      PhotoFolder.photos[this.getIndexBefore(previous_index)].preload();
    },
    getIndexBefore: function(index){
      return index - 1 < 0 ? PhotoFolder.photoPaths.length - 1 : index - 1;
    },
    getIndexAfter: function(index){
      return index + 1 > PhotoFolder.photoPaths.length - 1 ? 0 : index + 1;
    },
    displayPhotoAtIndex: function(index){
      if(!PhotoFolder.locked){
        this.setCurrentIndex(index);

tells the loading indicator to display if the next photo isn't ready before the loading timeout length

        PhotoFolder.LoadingIndicatorView.instance().waitForTimeout();
        PhotoFolder.photos[index].preload($.proxy(function(){
          if(this.currentPhotoView){
            this.removePhotoView(this.currentPhotoView);
          }
          this.currentPhotoView = new PhotoFolder.PhotoView({
            photo: PhotoFolder.photos[index]
          });
          this.trigger('change',this.currentPhotoView.get('photo'),this.lastPhotoView
            ? this.lastPhotoView.get('photo')
            : false
          );
          PhotoFolder.locked = true;
          PhotoFolder.LoadingIndicatorView.instance().hide();
          $(this.currentPhotoView)
            .hide()
            .appendTo(this.photoElement)
            .fadeIn(PhotoFolder.config.fadeLength,$.proxy(function(){
              PhotoFolder.locked = false;
              if(this.queuedAction){

timeout prevents a race condition and old photos from not being removed if the user clicks to rapidly

                setTimeout($.proxy(function(){
                  if(this.queuedAction){
                    this.queuedAction.apply(this,[]);
                    this.queuedAction = null;
                  }
                },this),50);
              }
            },this)
          );
        },this));
      }else if(!this.queuedAction){
        this.queuedAction = function(){
          this.displayPhotoAtIndex(index);
        };
      }
    },
    removePhotoView: function(photo_view){
      setTimeout($.proxy(function(){
        $(photo_view).fadeOut(PhotoFolder.config.fadeLength,$.proxy(function(){
          $(photo_view).remove();
        },this));
      },this),PhotoFolder.config.fadeOutDelay);
    },
    resizeLinkElementsToFill: function(){
      $([this.nextLink,this.previousLink]).height($(this).height());
    }
  });
  
  PhotoFolder.LayoutView.bind('change',function(view,current_photo,old_photo){
    if(PhotoFolder.config.setWindowTitle){
      document.title = current_photo.name;
    }
  });
  

Once LayoutView is ready bind up/down/left/right keys to navigation events

  PhotoFolder.LayoutView.ready(function(){
    if(PhotoFolder.config.bindKeyboardEvents){
      $(document).keydown(function(event){
        switch(event.keyCode){
          case 32: //space
            break;
          case 37: //left
          case 38: //up
            PhotoFolder.LayoutView.instance().displayPreviousPhoto();
            return false;
          case 39: //right
          case 40: //down
            PhotoFolder.LayoutView.instance().displayNextPhoto();
            return false;
        }
      });
    }
  });
  
  PhotoFolder.LoadingIndicatorView = $.view(function(){
    this.timeout = null;
    return $(this.div({className:'loading_indicator'},
      this.div()
    )).fadeOut(1);
  },{
    waitForTimeout: function(){
      if(!this.timeout){
        this.timeout = setTimeout(this.show,PhotoFolder.config.loadingTimeout);
      }
    },
    show: function(){
      $(this).fadeIn(PhotoFolder.config.fadeLength);
    },
    hide: function(){
      $(this).fadeOut(PhotoFolder.config.fadeLength);
      clearTimeout(this.timeout);
      this.timeout = null;
    }
  });

AlbumListView creates a

  PhotoFolder.AlbumListView = $.view(function(){
    PhotoFolder.LayoutView.bind('change',function(view,photo){
      this.activateAlbumByPath(photo.album.path);
    },this);
    var last_depth = 1;
    this.listItemsIndexedByAlbumPath = {};
    return this.ul(
      this.map(PhotoFolder.albums,function(album){
        var first_photo_path = album.path + '/1';

Preload the first photo in each album, but defer it so that the first photo to display loads first

        setTimeout(function(){
          PhotoFolder.photos[$.inArray(first_photo_path,PhotoFolder.photoPaths)].preload();
        },1000);
        var nodes = [
          (last_depth != album.depth && album.depth > 1) ? this.li(album.parentName) : false,
          this.listItemsIndexedByAlbumPath[album.path] = this.li({
            className: 'indent_' + album.depth
          },
            $(this.a({
              href: '#' + first_photo_path
            },album.name))
          )
        ];
        last_depth = album.depth;
        return nodes;
      })
    );
  },{
    activateAlbumByPath: function(path){
      $('li',this).removeClass('active');
      $(this.listItemsIndexedByAlbumPath[path]).addClass('active');
    }
  });
  

PhotoView contains the img element of a given photo the image will always have been pre loaded when this view is initialized. Multiple PhotoView instances will be on the screen at once when the photos are cross fading.

  PhotoFolder.PhotoView = $.view(function(){
    this.ready(this.center);
    $(window).resize(this.center);
    return this.img({
      className: 'photo',
      src: this.get('photo').src,
      height: this.get('photo').height,
      width: this.get('photo').width
    });
  },{

center the image element on screen

    center: function(){
      $(this).css({
        top: + (($(PhotoFolder.LayoutView.instance()).height() - $(this).outerHeight()) / 2) + 'px',
        left: + (($(PhotoFolder.LayoutView.instance()).width() - $(this).outerWidth()) / 2) + 'px'
      });
    }
  });
  
  return PhotoFolder;
})();