├── lib ├── .gitignore ├── rack_request.rb ├── flickr_response.rb ├── dm_pagination.rb ├── flickr_import_migrate.rb ├── flickr_archivr.rb └── flickr_import.rb ├── models ├── .gitignore ├── site_config.rb ├── link_photo_tag.rb ├── link_photo_place.rb ├── link_photo_photoset.rb ├── link_person_photo.rb ├── place.rb ├── tag.rb ├── person.rb ├── user.rb ├── photoset.rb └── photo.rb ├── spec ├── fixtures.rb ├── controller_spec.rb ├── models_spec.rb └── environment.rb ├── views ├── me.erb ├── search.erb ├── error │ ├── 500.erb │ ├── 403.erb │ ├── 404.erb │ └── no-login.erb ├── flickr_error.erb ├── about.erb ├── photos │ ├── recent.erb │ ├── index.erb │ ├── list.erb │ └── view.erb ├── tags.erb ├── index.erb ├── sets.erb ├── people.erb ├── partial │ └── context_table.erb └── layout.erb ├── .gitignore ├── .rvmrc ├── public ├── img │ ├── clear50.png │ ├── fblogin.png │ ├── fllogin.png │ ├── fllogin.psd │ ├── loading.gif │ └── twlogin.png ├── robots.txt ├── js │ ├── site.js │ └── libs │ │ └── bootstrap-dropdown.js └── css │ ├── site.css │ └── bootstrap-1.4.0.min.css ├── images ├── flickr-archivr-tag-view.png ├── flickr-archivr-one-photo.png ├── flickr-archivr-person-view.png └── flickr-archivr-photostream.png ├── config.ru ├── update-sets.sh ├── update.sh ├── helpers.rb ├── start.sh ├── config.yml.template ├── controllers ├── main.rb ├── auth.rb └── photos.rb ├── Gemfile ├── Rakefile ├── environment.rb └── README.markdown /lib/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /models/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/fixtures.rb: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /views/me.erb: -------------------------------------------------------------------------------- 1 |

<%= @username %>

2 | 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .bundle 2 | .DS_Store 3 | config.yml 4 | Gemfile.lock 5 | public/images 6 | -------------------------------------------------------------------------------- /.rvmrc: -------------------------------------------------------------------------------- 1 | rvm 1.9.2 2 | if ! command -v bundle ; then 3 | gem install bundler 4 | fi 5 | -------------------------------------------------------------------------------- /public/img/clear50.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aaronpk/Flickr-Archiver-Ruby/main/public/img/clear50.png -------------------------------------------------------------------------------- /public/img/fblogin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aaronpk/Flickr-Archiver-Ruby/main/public/img/fblogin.png -------------------------------------------------------------------------------- /public/img/fllogin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aaronpk/Flickr-Archiver-Ruby/main/public/img/fllogin.png -------------------------------------------------------------------------------- /public/img/fllogin.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aaronpk/Flickr-Archiver-Ruby/main/public/img/fllogin.psd -------------------------------------------------------------------------------- /public/img/loading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aaronpk/Flickr-Archiver-Ruby/main/public/img/loading.gif -------------------------------------------------------------------------------- /public/img/twlogin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aaronpk/Flickr-Archiver-Ruby/main/public/img/twlogin.png -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # See http://www.robotstxt.org/wc/norobots.html for documentation on how to use the robots.txt file 2 | -------------------------------------------------------------------------------- /images/flickr-archivr-tag-view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aaronpk/Flickr-Archiver-Ruby/main/images/flickr-archivr-tag-view.png -------------------------------------------------------------------------------- /models/site_config.rb: -------------------------------------------------------------------------------- 1 | module FlickrArchivr 2 | class SiteConfig < Hashie::Mash 3 | def key; self['key'] end 4 | end 5 | end -------------------------------------------------------------------------------- /images/flickr-archivr-one-photo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aaronpk/Flickr-Archiver-Ruby/main/images/flickr-archivr-one-photo.png -------------------------------------------------------------------------------- /images/flickr-archivr-person-view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aaronpk/Flickr-Archiver-Ruby/main/images/flickr-archivr-person-view.png -------------------------------------------------------------------------------- /images/flickr-archivr-photostream.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aaronpk/Flickr-Archiver-Ruby/main/images/flickr-archivr-photostream.png -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | require File.join(File.expand_path(File.dirname(__FILE__)), 'environment.rb') 2 | 3 | map '/' do 4 | run Sinatra::Application 5 | end -------------------------------------------------------------------------------- /models/link_photo_tag.rb: -------------------------------------------------------------------------------- 1 | class PhotoTag 2 | include DataMapper::Resource 3 | belongs_to :photo, :key => true 4 | belongs_to :tag, :key => true 5 | end -------------------------------------------------------------------------------- /models/link_photo_place.rb: -------------------------------------------------------------------------------- 1 | class PhotoPlace 2 | include DataMapper::Resource 3 | belongs_to :photo, :key => true 4 | belongs_to :place, :key => true 5 | end -------------------------------------------------------------------------------- /models/link_photo_photoset.rb: -------------------------------------------------------------------------------- 1 | class PhotoPhotoset 2 | include DataMapper::Resource 3 | belongs_to :photo, :key => true 4 | belongs_to :photoset, :key => true 5 | end -------------------------------------------------------------------------------- /update-sets.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd /web/Flickr-Archiver 4 | /usr/local/bin/rake flickr:sets[caseorganic] --trace 5 | /usr/local/bin/rake flickr:sets[aaronparecki] --trace 6 | 7 | 8 | -------------------------------------------------------------------------------- /update.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd /web/Flickr-Archiver 4 | /usr/local/bin/rake flickr:update[caseorganic] --trace 5 | /usr/local/bin/rake flickr:update[aaronparecki] --trace 6 | 7 | 8 | -------------------------------------------------------------------------------- /helpers.rb: -------------------------------------------------------------------------------- 1 | class Sinatra::Base 2 | helpers do 3 | def h(text); Rack::Utils.escape_html text end 4 | def partial(page, options={}) 5 | erb page, options.merge!(:layout => false) 6 | end 7 | end 8 | end -------------------------------------------------------------------------------- /models/link_person_photo.rb: -------------------------------------------------------------------------------- 1 | class PersonPhoto 2 | include DataMapper::Resource 3 | property :x, Integer 4 | property :y, Integer 5 | property :w, Integer 6 | property :h, Integer 7 | belongs_to :photo, :key => true 8 | belongs_to :person, :key => true 9 | end -------------------------------------------------------------------------------- /views/search.erb: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
5 |

Search is not yet available

6 |

Sorry!

7 |
8 |
9 | 10 |
-------------------------------------------------------------------------------- /views/error/500.erb: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
5 |

Server Error

6 |

Something went wrong!

7 |
8 |
9 | 10 |
-------------------------------------------------------------------------------- /views/flickr_error.erb: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
5 |

Flickr Error

6 |

<%==@error.msg%>

7 |
8 |
9 | 10 |
-------------------------------------------------------------------------------- /lib/rack_request.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | class Request 3 | def url_without_path 4 | url = scheme + "://" 5 | url << host 6 | 7 | if scheme == "https" && port != 443 || 8 | scheme == "http" && port != 80 9 | url << ":#{port}" 10 | end 11 | url 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /views/about.erb: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
5 |

Flickr Archivr

6 |

Back up your entire Flickr photostream!

7 |
8 |
9 | 10 |

11 | 12 |
-------------------------------------------------------------------------------- /start.sh: -------------------------------------------------------------------------------- 1 | 2 | if [ $# -eq 0 ] ; then 3 | echo Starting in development mode... 4 | bundle exec shotgun -P public -p 3000 5 | exit 0 6 | fi 7 | 8 | if [ $1 == 'production' ] ; then 9 | echo Starting in production mode... 10 | bundle exec rackup 11 | exit 0 12 | fi 13 | 14 | echo Usage: $0 production 15 | exit 1 16 | 17 | -------------------------------------------------------------------------------- /views/error/403.erb: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
5 |

Not Found

6 |

Sorry, whatever it was you were looking for doesn't seem to exist!

7 |
8 |
9 | 10 |
-------------------------------------------------------------------------------- /views/error/404.erb: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
5 |

Not Found

6 |

Sorry, whatever it was you were looking for doesn't seem to exist

7 |
8 |
9 | 10 |
-------------------------------------------------------------------------------- /spec/controller_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join File.expand_path(File.dirname(__FILE__)), 'environment.rb' 2 | 3 | describe Controller do 4 | include Rack::Test::Methods 5 | def app; Controller end 6 | 7 | describe 'the index route' do 8 | it 'should load correctly' do 9 | get '/' 10 | expect_that { last_response.ok? } 11 | expect_that { last_response.body.length > 0 } 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/models_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join File.expand_path(File.dirname(__FILE__)), 'environment.rb' 2 | 3 | =begin 4 | # Example: 5 | describe Game do 6 | describe 'the score' do 7 | it 'should increase from zero to ten points on first strike' do 8 | @game = Game.create :player_name => 'Dean Martin' 9 | expect_that { @game.points == 0 } 10 | @game.strike! 11 | expect_that { @game.points == 10 } 12 | end 13 | end 14 | end 15 | =end -------------------------------------------------------------------------------- /views/error/no-login.erb: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
5 |

Can't Sign In

6 |

Sorry, this site does not allow other people to sign in! If you'd like to set up your own site,
you can download the source code from Github

7 |
8 |
9 | 10 |
-------------------------------------------------------------------------------- /views/photos/recent.erb: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
5 | 6 |

Recent Photos

7 | 8 |
    9 | <% @photos.each do |item| puts item['photo'].to_hash %> 10 |
  • 11 | <% end %> 12 |
13 | 14 |
15 |
16 | 17 |
-------------------------------------------------------------------------------- /views/tags.erb: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 5 |
6 | 7 |
8 |
    9 | <% @tags.each_with_index do |tag,i| %> 10 |
  • <%= tag.name %>
  • 11 | <% end %> 12 |
13 |
14 | 15 |
16 | 17 |
18 | 19 |
20 | -------------------------------------------------------------------------------- /lib/flickr_response.rb: -------------------------------------------------------------------------------- 1 | module FlickRaw 2 | class Response 3 | def to_hash 4 | hash = {} 5 | @h.each {|k,v| 6 | hash[k] = case v 7 | when FlickRaw::Response then v.to_hash 8 | when FlickRaw::ResponseList then v.to_a.collect {|e| e.to_hash} 9 | else v 10 | end 11 | } 12 | hash 13 | end 14 | end 15 | 16 | # class ResponseList 17 | # def to_hash 18 | # arr = [] 19 | # @a.each {|v| 20 | # arr << v.to_hash 21 | # } 22 | # arr 23 | # end 24 | # end 25 | end -------------------------------------------------------------------------------- /config.yml.template: -------------------------------------------------------------------------------- 1 | development: 2 | database: "mysql://flickr:archivr@localhost/flickrarchivr" 3 | flickr_consumer_key: 4 | flickr_consumer_secret: 5 | ga_id: 6 | photo_root: /Users/you/Flickr-Archiver/public/images/ 7 | photo_url_root: /images/ 8 | allow_create_users: true 9 | production: 10 | database: "mysql://flickr:archivr@localhost/flickrarchivr" 11 | flickr_consumer_key: 12 | flickr_consumer_secret: 13 | ga_id: 14 | photo_root: /Users/you/Flickr-Archiver/public/images/ 15 | photo_url_root: /images/ 16 | allow_create_users: true 17 | -------------------------------------------------------------------------------- /views/photos/index.erb: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
5 | 6 |

Photos

7 | 8 |
    9 | <% @photos.each do |photo| %> 10 |
  • 11 | 12 | /> 13 | 14 |
  • 15 | <% end %> 16 |
17 | 18 | 21 | 22 |
23 |
24 | 25 |
26 | -------------------------------------------------------------------------------- /views/index.erb: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
5 | 11 |

Sign in to get started

12 | 13 | 14 |
15 |
16 |
17 | 18 |
19 |
20 |
21 | 22 |
23 |
24 | 25 |
-------------------------------------------------------------------------------- /public/js/site.js: -------------------------------------------------------------------------------- 1 | $(function(){ 2 | 3 | /** 4 | * Show closer map on hover 5 | */ 6 | $(".photo_map").bind("mouseover", function(){ 7 | $("#photo_map_near").show(); 8 | $("#photo_map_far").hide(); 9 | }).bind("mouseout", function(){ 10 | $("#photo_map_near").hide(); 11 | $("#photo_map_far").show(); 12 | }); 13 | 14 | /** 15 | * Photo size dropdown 16 | */ 17 | $(".photo-sizes a.dropdown-toggle").bind("click", function(){ 18 | if($(".photo-sizes a.dropdown-toggle").hasClass("closed")) { 19 | $(".photo-sizes a.dropdown-toggle").removeClass("closed").addClass("open"); 20 | $(".photo-sizes .hidden").removeClass("hidden"); 21 | } else { 22 | $(".photo-sizes a.dropdown-toggle").removeClass("open").addClass("closed"); 23 | $(".photo-sizes .start-hidden").addClass("hidden"); 24 | } 25 | }); 26 | 27 | }); -------------------------------------------------------------------------------- /controllers/main.rb: -------------------------------------------------------------------------------- 1 | before do 2 | begin 3 | @flickr = FlickRaw::Flickr.new 4 | 5 | unless session[:access_token].nil? 6 | # puts "Already have an access token: #{session[:access_token]}" 7 | @flickr.access_token = session[:access_token] 8 | @flickr.access_secret = session[:access_token_secret] 9 | end 10 | rescue SocketError => e 11 | @flickr = nil 12 | end 13 | 14 | if session[:user_id] 15 | @me = User.get session[:user_id] 16 | else 17 | @me = nil 18 | end 19 | end 20 | 21 | get '/?' do 22 | if @me 23 | redirect "/#{@me.username}" 24 | else 25 | erb :index 26 | end 27 | end 28 | 29 | get '/about/?' do 30 | erb :about 31 | end 32 | 33 | get '/:username/search' do 34 | erb :search 35 | end 36 | 37 | post '/:username/search' do 38 | #@search_sets = Photoset.all(:user_id => @user.id, ) 39 | erb :search 40 | end 41 | -------------------------------------------------------------------------------- /lib/dm_pagination.rb: -------------------------------------------------------------------------------- 1 | 2 | module DataMapper 3 | module Pagination 4 | 5 | def page page = nil, options = {} 6 | options, page = page, nil if page.is_a? Hash 7 | page_param = pager_option(:page_param, options) 8 | page ||= pager_option page_param, options 9 | options.delete page_param 10 | page = 1 unless (page = page.to_i) && page > 1 11 | per_page = pager_option(:per_page, options).to_i 12 | query = options.dup 13 | collection = new_collection scoped_query(options = { 14 | :limit => per_page, 15 | :offset => (page - 1) * per_page, 16 | #:order => [:id.desc] 17 | }.merge(query)) 18 | #query.delete :order 19 | options.merge! :total => count(query), page_param => page, :page_param => page_param 20 | collection.pager = DataMapper::Pager.new options 21 | collection 22 | end 23 | 24 | end 25 | end -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem 'yajl-ruby', '~> 1.4.1', :require => 'yajl/json_gem' 4 | 5 | gem 'sinatra', '1.3.2' 6 | gem 'sinatra-contrib', '1.3.1', :require => 'sinatra/namespace' 7 | gem 'sinatra-flash', '0.3.0', :require => 'sinatra/flash' 8 | gem 'sinatra-support', '1.2.2', :require => "sinatra/support" 9 | gem 'rainbows', '4.3.1', :require => nil 10 | gem 'erubis' 11 | 12 | gem 'dm-core' 13 | gem 'dm-timestamps' 14 | gem 'dm-migrations' 15 | gem 'dm-aggregates' 16 | gem 'dm-mysql-adapter' 17 | gem 'dm-pager' 18 | 19 | gem 'rake' 20 | gem 'hashie' 21 | 22 | gem 'flickraw' 23 | gem 'thin' 24 | 25 | group :development do 26 | gem 'shotgun', :require => nil 27 | end 28 | 29 | group :test do 30 | gem 'rack-test', '0.5.6', :require => 'rack/test' 31 | gem 'wrong', '0.5.0' 32 | gem 'webmock', '1.7.4' 33 | end 34 | -------------------------------------------------------------------------------- /spec/environment.rb: -------------------------------------------------------------------------------- 1 | ENV['RACK_ENV'] = 'test' 2 | raise 'Forget it.' if ENV['RACK_ENV'] == 'production' 3 | require File.join(File.expand_path(File.dirname(__FILE__)), '..', 'environment.rb') 4 | Bundler.require :test 5 | require 'minitest/autorun' 6 | require 'wrong/adapters/minitest' 7 | require 'webmock' 8 | require 'webmock/http_lib_adapters/em_http_request/em_http_request_1_x' 9 | Wrong.config.alias_assert :expect_that 10 | require File.join(File.expand_path(File.dirname(__FILE__)), 'fixtures.rb') 11 | 12 | def mock_app(base=Sinatra::Base, &block); @app = Sinatra.new(base, &block) end 13 | def app=(new_app); @app = new_app end 14 | 15 | include WebMock::API 16 | 17 | =begin 18 | def file_upload_hash 19 | {:type => "image/jpeg", 20 | :tempfile => Rack::Test::UploadedFile.new(File.join('tests', 'files', 'rosemarys-baby.jpg'), "image/jpeg"), 21 | :name => "photo[file]", 22 | :filename=>"rosemarys-baby.jpg", 23 | :head=>"Content-Disposition: form-data; name=\"photo[file]\"; filename=\"rosemarys-baby.jpg\"\r\nContent-Type: image/jpeg\r\nContent-Length: 28333\r\n"} 24 | end 25 | =end 26 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | def init(env=ENV['RACK_ENV']); end 2 | require File.join('.', 'environment.rb') 3 | 4 | namespace :db do 5 | task :bootstrap do 6 | init 7 | DataMapper.auto_migrate! 8 | end 9 | task :migrate do 10 | init 11 | DataMapper.auto_upgrade! 12 | end 13 | end 14 | 15 | namespace :flickr do 16 | 17 | task :import, :username do |t, username| 18 | init 19 | FlickrImport.do_import username 20 | end 21 | 22 | task :update, :username do |t, username| 23 | FlickrImport.do_update username 24 | end 25 | 26 | task :sets, :username do |t, username| 27 | FlickrImport.import_sets username 28 | end 29 | 30 | # Utilities 31 | 32 | task :update_counts, :username do |t, username| 33 | FlickrImport.update_counts username 34 | end 35 | 36 | # Migration scripts. No longer needed 37 | 38 | task :add_local_path do 39 | FlickrImportMigrate.add_local_path 40 | end 41 | 42 | task :secrify do 43 | FlickrImportMigrate.secrify 44 | end 45 | 46 | task :test, :username do |t, username| 47 | init 48 | FlickrImport.test username 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /views/sets.erb: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 5 |
6 | 7 |
8 | <% 9 | @sets.each_with_index do |photoset,i| 10 | next if photoset.cover_photo.nil? # This shouldn't happen regularly, but may happen if a set was deleted and the list was viewed before the sync script caught up 11 | # size = (@page.to_i == 1 && i < 6 ? 's' : 'sq') 12 | size = 's' 13 | %> 14 |
15 |
16 | 17 | /> 18 | 19 |

<%== photoset.title %>

20 |

<%= photoset.num_photos %> photos

21 |
22 |
23 | <% 24 | end 25 | %> 26 |
27 | 28 | 31 | 32 |
33 | 34 |
35 | 36 |
37 | -------------------------------------------------------------------------------- /views/people.erb: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 5 |
6 | 7 |
8 | <% 9 | @people.each_with_index do |person,i| 10 | cover = person.cover_photo 11 | next if cover.nil? # This shouldn't happen regularly, but may happen if a person was deleted and the list was viewed before the sync script caught up, or if a person has no public photos 12 | # size = (@page.to_i == 1 && i < 6 ? 's' : 'sq') 13 | size = 's' 14 | %> 15 |
16 |
17 | 18 | /> 19 | 20 |

<%== person.display_name %>

21 |

<%= person.num %> photos

22 |
23 |
24 | <% 25 | end 26 | %> 27 |
28 | 29 | 32 | 33 |
34 | 35 |
36 | 37 |
38 | -------------------------------------------------------------------------------- /lib/flickr_import_migrate.rb: -------------------------------------------------------------------------------- 1 | class FlickrImportMigrate 2 | def self.add_local_path 3 | Photo.all.each do |p| 4 | puts p.id 5 | Photo.sizes.each do |s| 6 | if p.send("url_#{s}") 7 | puts p.path(s)+p.filename(s) 8 | p.send("local_path_#{s}=", p.path(s)+p.filename(s)) 9 | else 10 | puts "Missing size #{s}" 11 | end 12 | end 13 | if p.media == 'video' 14 | p.local_path_v = p.path('v')+p.filename('v') 15 | puts p.path('v')+p.filename('v') 16 | end 17 | p.save() 18 | end 19 | end 20 | 21 | def self.secrify 22 | Photo.all.each do |p| 23 | json = JSON.parse p.raw 24 | p.original_secret = json['originalsecret'] 25 | p.format = json['originalformat'] 26 | Photo.sizes.each do |s| 27 | if (path = p.send("local_path_#{s}")) 28 | if path != p.path(s)+p.filename(s) 29 | old_path = SiteConfig.photo_root + path 30 | new_path = SiteConfig.photo_root + p.path(s)+p.filename(s) 31 | p.send("local_path_#{s}=", p.path(s)+p.filename(s)) 32 | puts p.path(s)+p.filename(s) 33 | `mv #{old_path} #{new_path}` 34 | end 35 | end 36 | end 37 | if p.media == 'video' 38 | old_path = SiteConfig.photo_root + p.local_path_v 39 | new_path = SiteConfig.photo_root + p.path('v')+p.filename('v') 40 | p.local_path_v = p.path('v')+p.filename('v') 41 | puts p.path('v')+p.filename('v') 42 | `mv #{old_path} #{new_path}` 43 | end 44 | p.save() 45 | end 46 | end 47 | end -------------------------------------------------------------------------------- /environment.rb: -------------------------------------------------------------------------------- 1 | Encoding.default_internal = 'UTF-8' 2 | require 'rubygems' 3 | require 'bundler/setup' 4 | Bundler.require 5 | require File.join(File.expand_path(File.dirname(__FILE__)), 'helpers.rb') 6 | Dir.glob(['lib', 'models'].map! {|d| File.join File.expand_path(File.dirname(__FILE__)), d, '*.rb'}).each {|f| require f} 7 | 8 | if File.exists?('config.yml') 9 | SiteConfig = FlickrArchivr::SiteConfig.new YAML.load_file('config.yml')[Sinatra::Base.environment.to_s] 10 | else 11 | abort "You must create config.yml - copy and edit the example from config.yml.template to get started." 12 | end 13 | 14 | puts "Starting in #{Sinatra::Base.environment} mode.." 15 | 16 | helpers Sinatra::UserAgentHelpers 17 | 18 | set :raise_errors, true 19 | set :show_exceptions, false 20 | set :method_override, true 21 | set :public_folder, 'public' 22 | 23 | use Rack::Session::Cookie, :key => 'website', 24 | :path => '/', 25 | :expire_after => 2592000, 26 | :secret => 'YQjEEqd8' 27 | 28 | configure do 29 | FlickRaw.api_key = SiteConfig.flickr_consumer_key 30 | FlickRaw.shared_secret = SiteConfig.flickr_consumer_secret 31 | DataMapper.finalize 32 | DataMapper.setup :default, SiteConfig.database 33 | 34 | #DataMapper.auto_migrate! # Create the tables 35 | #DataMapper.auto_upgrade! # Tries to alter tables preserving data 36 | 37 | DataMapper::Model.raise_on_save_failure = true 38 | end 39 | 40 | configure :development do 41 | use Rack::CommonLogger 42 | Bundler.require :development 43 | end 44 | 45 | configure :test do 46 | end 47 | 48 | configure :production do 49 | end 50 | 51 | not_found do 52 | erb :'error/404' 53 | end 54 | 55 | error do 56 | # Implement error reporting such as Airbrake here. 57 | erb :'error/500' 58 | end 59 | 60 | Dir.glob(['controllers/**'].map! {|d| File.join d, '*.rb'}).each {|f| require_relative f} 61 | -------------------------------------------------------------------------------- /views/partial/context_table.erb: -------------------------------------------------------------------------------- 1 |
<%= @item.display_name + (@item.class == User ? "'s photostream" : '') %>
2 | 3 | 4 | <% 5 | context = @item.get_context(@me, @photo.id, 4) 6 | position = context.to_a.index {|a| @photo.id == a.id} 7 | puts "Total items: #{context.length} Position: #{position}" 8 | if context.length <= 5 9 | range = 0..4 10 | elsif context.length == 6 11 | if position <= 2 12 | range = 0..4 13 | else 14 | range = 1..5 15 | end 16 | elsif context.length == 7 17 | if position <= 2 18 | range = 0..4 19 | elsif position == 3 20 | range = 1..5 21 | else 22 | range = 2..6 23 | end 24 | elsif context.length == 8 25 | if position <= 2 26 | range = 0..4 27 | elsif position >= 5 28 | range = 3..7 29 | else 30 | range = (position-2)..(position+2) 31 | end 32 | elsif context.length == 9 33 | if position <= 2 34 | range = 0..4 35 | elsif position >= 6 36 | range = 4..8 37 | else 38 | range = (position-2)..(position+2) 39 | end 40 | end 41 | %> 42 | <% context.to_a[range].each do |context| %> 43 | > 44 | 45 | 46 | 47 | 48 | <% end %> 49 | <% 50 | if context.length < 5 51 | ((context.length+1)..5).each do |i| 52 | %><% 53 | end 54 | end 55 | %> 56 | 57 |
58 | -------------------------------------------------------------------------------- /public/js/libs/bootstrap-dropdown.js: -------------------------------------------------------------------------------- 1 | /* ============================================================ 2 | * bootstrap-dropdown.js v1.4.0 3 | * http://twitter.github.com/bootstrap/javascript.html#dropdown 4 | * ============================================================ 5 | * Copyright 2011 Twitter, Inc. 6 | * 7 | * Licensed under the Apache License, Version 2.0 (the "License"); 8 | * you may not use this file except in compliance with the License. 9 | * You may obtain a copy of the License at 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | * ============================================================ */ 19 | 20 | 21 | !function( $ ){ 22 | 23 | "use strict" 24 | 25 | /* DROPDOWN PLUGIN DEFINITION 26 | * ========================== */ 27 | 28 | $.fn.dropdown = function ( selector ) { 29 | return this.each(function () { 30 | $(this).delegate(selector || d, 'click', function (e) { 31 | var li = $(this).parent('li') 32 | , isActive = li.hasClass('open') 33 | 34 | clearMenus() 35 | !isActive && li.toggleClass('open') 36 | return false 37 | }) 38 | }) 39 | } 40 | 41 | /* APPLY TO STANDARD DROPDOWN ELEMENTS 42 | * =================================== */ 43 | 44 | var d = 'a.menu, .dropdown-toggle' 45 | 46 | function clearMenus() { 47 | $(d).parent('li').removeClass('open') 48 | } 49 | 50 | $(function () { 51 | $('html').bind("click", clearMenus) 52 | $('body').dropdown( '[data-dropdown] a.menu, [data-dropdown] .dropdown-toggle' ) 53 | }) 54 | 55 | }( window.jQuery || window.ender ); 56 | -------------------------------------------------------------------------------- /controllers/auth.rb: -------------------------------------------------------------------------------- 1 | get '/auth/signout' do 2 | session[:access_token] = nil 3 | session[:access_token_secret] = nil 4 | session[:user_id] = nil 5 | redirect '/' 6 | end 7 | 8 | get '/auth/flickr' do 9 | # Send to the Flickr auth URL 10 | session[:access_token] = nil 11 | session[:access_token_secret] = nil 12 | session[:user_id] = nil 13 | token = @flickr.get_request_token({:oauth_callback => "#{request.url_without_path}/auth/flickr/callback"}) 14 | auth_url = @flickr.get_authorize_url(token['oauth_token'], :perms => 'read') 15 | session[:request_token] = token['oauth_token'] 16 | session[:request_token_secret] = token['oauth_token_secret'] 17 | redirect auth_url 18 | end 19 | 20 | get '/auth/flickr/callback' do 21 | puts "Redirect from Flickr" 22 | puts params 23 | begin 24 | @flickr.get_access_token(session[:request_token], session[:request_token_secret], params[:oauth_verifier]) 25 | login = @flickr.test.login 26 | puts "You are now authenticated as #{login.username} with token #{@flickr.access_token} and secret #{@flickr.access_secret}" 27 | session[:access_token] = @flickr.access_token 28 | session[:access_token_secret] = @flickr.access_secret 29 | session[:request_token] = nil 30 | session[:request_token_secret] = nil 31 | 32 | # Retrieve the user or create a new user account 33 | user = User.first :username => login.username 34 | if user.nil? 35 | if SiteConfig.allow_create_users 36 | user = User.new :username => login.username, :nsid => login.id, :access_token => @flickr.access_token, :access_secret => @flickr.access_secret 37 | user.save 38 | session[:user_id] = user.id 39 | redirect '/' + user.username 40 | else 41 | redirect '/no-login' 42 | end 43 | else 44 | session[:user_id] = user.id 45 | redirect '/' + user.username 46 | end 47 | rescue FlickRaw::FailedResponse => e 48 | puts "Authentication Failed : #{e.msg}" 49 | redirect '/auth/flickr/error' 50 | end 51 | end 52 | 53 | get '/auth/flickr/error' do 54 | session[:user_id] = nil 55 | session[:access_token] = nil 56 | erb :error 57 | end 58 | 59 | get '/no-login' do 60 | erb :'error/no-login' 61 | end 62 | -------------------------------------------------------------------------------- /lib/flickr_archivr.rb: -------------------------------------------------------------------------------- 1 | module FlickrArchivr 2 | 3 | module PhotoList 4 | @@usernames = {} 5 | 6 | def self.username_from_id(id) 7 | if @@usernames[id].nil? 8 | @@usernames[id] = User.get(id).username 9 | end 10 | @@usernames[id] 11 | end 12 | 13 | def username_from_id(id) 14 | FlickrArchivr::PhotoList.username_from_id id 15 | end 16 | 17 | def get_photos(auth_user, page, per_page) 18 | if auth_user && auth_user.id == (self.class == User ? self.id : self.user_id) 19 | self.photos.all(:order => [:date_uploaded.desc]).page(page || 1, :per_page => per_page) 20 | else 21 | self.photos.all(:public => true, :order => [:date_uploaded.desc]).page(page || 1, :per_page => per_page) 22 | end 23 | end 24 | 25 | def get_photos_for_date(auth_user, page, per_page, year, month=nil, day=nil) 26 | if year && month && day 27 | range = (DateTime.parse("#{year}-#{month}-#{day}")..DateTime.parse("#{year}-#{month}-#{day} 23:59:59")) 28 | elsif year && month 29 | if [1,3,5,7,8,10,12].include? month 30 | lastday = 31 31 | elsif month.to_i == 2 32 | if year.to_i % 4 == 0 33 | lastday = 29 34 | else 35 | lastday = 28 36 | end 37 | else 38 | lastday = 30 39 | end 40 | range = (DateTime.parse("#{year}-#{month}-01")..DateTime.parse("#{year}-#{month}-#{lastday} 23:59:59")) 41 | else 42 | range = (DateTime.parse("#{year}-01-01")..DateTime.parse("#{year}-12-31 23:59:59")) 43 | end 44 | 45 | if auth_user && auth_user.id == (self.class == User ? self.id : self.user_id) 46 | self.photos.all(:date_taken => range, :order => [:date_uploaded.desc]).page(page || 1, :per_page => per_page) 47 | else 48 | self.photos.all(:date_taken => range, :public => true, :order => [:date_uploaded.desc]).page(page || 1, :per_page => per_page) 49 | end 50 | end 51 | 52 | # Return the photo's sequence number given this ordering of photos 53 | def row_for_photo(auth_user, photo_id) 54 | _order_photos('row_num', auth_user, photo_id, 1) 55 | end 56 | 57 | # Return the page number the given photo appears on for this ordering of photos 58 | def page_for_photo(auth_user, photo_id, per_page) 59 | _order_photos('page_num', auth_user, photo_id, per_page) 60 | end 61 | end 62 | 63 | class Error < Exception 64 | 65 | end 66 | class NotFoundError < Error 67 | def erb_template 68 | "error/404" 69 | end 70 | end 71 | class ForbiddenError < Error 72 | def erb_template 73 | "error/403" 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /models/place.rb: -------------------------------------------------------------------------------- 1 | class Place 2 | include DataMapper::Resource 3 | property :id, Serial 4 | belongs_to :user 5 | has n, :photos, :through => Resource 6 | 7 | property :type, String, :length => 20 8 | property :name, String, :length => 255 9 | property :flickr_id, String, :length => 30 10 | property :woe_id, String, :length => 30 11 | 12 | property :num, Integer, :default => 0 13 | property :created_at, DateTime 14 | property :updated_at, DateTime 15 | 16 | include FlickrArchivr::PhotoList 17 | 18 | def display_name 19 | self.name 20 | end 21 | 22 | def list_type 23 | 'place' 24 | end 25 | 26 | # Returns the relative link to this item's page on this website 27 | def page(photo=nil) 28 | "/#{self.username_from_id(self.user_id)}/place/#{self.id}/#{self.title_urlsafe}" + (photo.nil? ? "" : "?show=#{photo.id}") 29 | end 30 | 31 | def title_urlsafe 32 | self.name.gsub(/[^A-Za-z0-9_-]/, '-').gsub(/-+/, '-') 33 | end 34 | 35 | def verify_permission!(user, auth_user) 36 | raise FlickrArchivr::NotFoundError if self.user_id != user.id 37 | true 38 | end 39 | 40 | def page_for_photo(auth_user, photo_id, per_page) 41 | repository.adapter.select('SELECT page_num FROM ( 42 | SELECT (@row_num := @row_num + 1) AS row_num, FLOOR((@row_num-1) / ?) + 1 AS page_num, id 43 | FROM ( 44 | SELECT photos.id, photos.date_uploaded 45 | FROM `photos` 46 | JOIN (SELECT @row_num := 0) r 47 | INNER JOIN `photo_places` ON `photos`.`id` = `photo_places`.`photo_id` 48 | INNER JOIN `places` ON `photo_places`.`place_id` = `places`.`id` 49 | WHERE `photo_places`.`place_id` = ? 50 | ' + (auth_user && auth_user.id == self.user_id ? '' : 'AND `photos`.`public` = 1') + ' 51 | GROUP BY `photos`.`id` 52 | ORDER BY `photos`.`date_uploaded` DESC 53 | ) AS photo_list 54 | ) AS tmp 55 | WHERE id = ? 56 | ', per_page, self.id, photo_id)[0] 57 | end 58 | 59 | def get_all_dates 60 | years = repository.adapter.select(' 61 | SELECT YEAR(date_taken) AS year, COUNT(1) AS num 62 | FROM photos 63 | JOIN photo_places lk ON photos.id = lk.photo_id 64 | WHERE lk.place_id = ? 65 | GROUP BY year 66 | ORDER BY year DESC 67 | ', self.id) 68 | months = repository.adapter.select(' 69 | SELECT YEAR(date_taken) AS year, MONTH(date_taken) AS month, COUNT(1) AS num 70 | FROM photos 71 | JOIN photo_places lk ON photos.id = lk.photo_id 72 | WHERE lk.place_id = ? 73 | GROUP BY year, month 74 | ORDER BY year DESC, month DESC 75 | ', self.id) 76 | days = [] 77 | # days = repository.adapter.select(' 78 | # SELECT YEAR(date_taken) AS year, MONTH(date_taken) AS month, DAY(date_taken) AS day 79 | # FROM photos 80 | # JOIN photo_places lk ON photos.id = lk.photo_id 81 | # WHERE lk.place_id = ? 82 | # GROUP BY year, month, day 83 | # ORDER BY year DESC, month DESC, day DESC 84 | # ', self.id) 85 | {:years => years, :months => months, :days => days} 86 | end 87 | 88 | def self.create_from_flickr(type, obj, user) 89 | place = Place.new 90 | place.user = user 91 | place.type = type 92 | place.name = obj._content 93 | place.flickr_id = obj.place_id 94 | place.woe_id = (obj.respond_to?('woeid') ? obj.woeid : '') 95 | place 96 | end 97 | 98 | def update_count! 99 | self.num = PhotoPlace.count :place_id => self.id 100 | self.save 101 | end 102 | end -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | Flickr Archiver 2 | =============== 3 | 4 | UNMAINTAINED: I am leaving this project here for historical purposes. Since it was originally written in 2012, there are a lot of changes needed to get it working again today. I am also personally not using Flickr as my primary photo archive anymore, so I don't need the main features this project provides which is continually syncing a copy of a Flickr account. Please check out my new project, [Flickr Archivr](https://github.com/aaronpk/Flickr-Archivr), which archives an entire Flickr account to a static website. 5 | 6 | --- 7 | 8 | This project is meant to back up a single user's Flickr photo stream. It can also serve as a public web mirror of the user's Flickr 9 | photos and sets. 10 | 11 | ![Flickr Archiver Screenshot](images/flickr-archivr-photostream.png "Flickr Archivr Screenshot") 12 | 13 | ![Flickr Archiver Screenshot](images/flickr-archivr-one-photo.png "Flickr Archivr Screenshot") 14 | 15 | 16 | Installation 17 | ------------ 18 | 19 | 1) Clone the project: 20 | 21 | $ git clone git@github.com:aaronpk/Flickr-Archiver.git 22 | 23 | 2) Install Bundler: 24 | 25 | $ gem install bundler 26 | 27 | 3) Create a Flickr app: http://www.flickr.com/services/apps/create/ 28 | 29 | 4) Create a mysql database for your photo archive. 30 | 31 | 5) Copy config.yml.template to config.yml. Edit it to include your Flickr 32 | consumer key and secret, database connection details, and to set the folder where you want to store the 33 | downloaded images. This should be in the "public" directory if you want 34 | them served automatically by the application's web server. 35 | 36 | 6) Install dependancies with Bundler: 37 | 38 | $ bundle install 39 | 40 | 7) Setup the database: 41 | 42 | $ rake db:bootstrap 43 | 44 | 8) Start the server: 45 | 46 | For development: 47 | $ bundle exec rackup -s thin 48 | 49 | For development, app reloads automatically: 50 | $ bundle exec shotgun -s thin -P public 51 | 52 | For production: 53 | $ bundle exec thin start -e production 54 | Look at the documentation for thin's command line. You can configure for multiple workers, etc.. 55 | 56 | That's it! Visit http://localhost:3000/ and sign in with your Flickr account! 57 | 58 | 59 | Initial Import 60 | -------------- 61 | 62 | After signing in and connecting your Flickr account, you can do the initial import of your photo stream. This 63 | is done with a rake task: 64 | 65 | $ rake flickr:import[username] 66 | 67 | After this finishes, you should have a complete archive of your Flickr stream. Visit http://localhost:3030/username and you 68 | should see everything there. 69 | 70 | If this errored out you can safely restart it and it will continue where it left off. This is accomplished by using the 71 | `import_timestamp` field on the `users` table. After this has finished successfully, you should update the field to the 72 | timestamp of the most recent photo in your stream. You can do this with the following SQL command: 73 | 74 | UPDATE users SET import_timestamp = (SELECT UNIX_TIMESTAMP(MAX(date_uploaded)) FROM photos WHERE user_id = 1) WHERE id = 1 75 | 76 | 77 | Keeping Updated 78 | --------------- 79 | 80 | From here on out, you will only need to download new photos and photos that have been modified. Luckily Flickr provides a nice 81 | API method for retrieving recently modified photos, which they say includes changes to the title, description, tags, or "just 82 | modified somehow :-)" 83 | 84 | $ rake flickr:update[username] 85 | 86 | Any changes to photos will cause them to be re-imported using this task! You can run this every 5 minutes, hour, day, or whatever. 87 | If you rename photos, the filename on disk will have changed, so the script will remove the old filename and re-download the photo. 88 | 89 | -------------------------------------------------------------------------------- /views/photos/list.erb: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
5 |

<%== @title %>

6 |
7 |
8 | 9 |
10 | 11 |
12 | 13 |
    14 | <% @photos.each do |photo| %> 15 |
  • 16 | 17 | /> 18 | 19 |
  • 20 | <% end %> 21 |
22 | <% if @photos.length == 0 %> 23 |
24 |
25 |

No Photos Found

26 |

Sorry, there are no photos for you here.

27 |
28 |
29 | <% end %> 30 | 31 | 34 | 35 |
36 |
37 | 38 | <% if @related_sets && @related_sets.length > 0 %> 39 | 47 | <% end %> 48 | 49 | <% if @related_people && @related_people.length > 0 %> 50 | 58 | <% end %> 59 | 60 | <% if @related_places && @related_places.length > 0 %> 61 | 69 | <% end %> 70 | 71 | <% if @related_dates %> 72 | 86 | <% end %> 87 | 88 | <% if @related_tags && @related_tags.length > 0 %> 89 | 97 | <% end %> 98 | 99 |
100 | 101 |
102 | 103 |
104 | -------------------------------------------------------------------------------- /models/tag.rb: -------------------------------------------------------------------------------- 1 | class Tag 2 | include DataMapper::Resource 3 | property :id, Serial 4 | belongs_to :user 5 | property :tag, String, :length => 255, :index => true # _content 6 | property :name, String, :length => 255 # raw 7 | property :machine_tag, Boolean, :default => false 8 | property :num, Integer, :default => 0 9 | property :created_at, DateTime 10 | property :updated_at, DateTime 11 | has n, :photos, :through => :photo_tag 12 | 13 | include FlickrArchivr::PhotoList 14 | 15 | def display_name 16 | self.name 17 | end 18 | 19 | def list_type 20 | 'tag' 21 | end 22 | 23 | # Returns the relative link to this item's page on this website 24 | def page(photo=nil) 25 | "/#{self.username_from_id(self.user_id)}/tag/#{self.id}/#{self.tag}" + (photo.nil? ? "" : "?show=#{photo.id}") 26 | end 27 | 28 | def verify_permission!(user, auth_user) 29 | raise FlickrArchivr::NotFoundError if self.user_id != user.id 30 | true 31 | end 32 | 33 | # Return the next and previous n photos given this ordering 34 | def get_context(auth_user, photo_id, num) 35 | row_num = self.row_for_photo auth_user, photo_id 36 | repository.adapter.select(' 37 | SELECT row_num, id, title, local_path_sq FROM ( 38 | SELECT (@row_num := @row_num + 1) AS row_num, id, title, local_path_sq 39 | FROM ( 40 | SELECT photos.id, photos.date_uploaded, photos.title, local_path_sq 41 | FROM `photos` 42 | JOIN (SELECT @row_num := 0) r 43 | INNER JOIN `photo_tags` ON `photos`.`id` = `photo_tags`.`photo_id` 44 | INNER JOIN `tags` ON `photo_tags`.`tag_id` = `tags`.`id` 45 | WHERE `photo_tags`.`tag_id` = ? 46 | ' + (auth_user && auth_user.id == self.user_id ? '' : 'AND `photos`.`public` = 1') + ' 47 | GROUP BY `photos`.`id` 48 | ORDER BY `photos`.`date_uploaded` DESC 49 | ) AS photo_list 50 | ) AS tmp 51 | WHERE row_num >= ? - ? AND row_num <= ? + ? 52 | ', self.id, row_num, num, row_num, num) 53 | end 54 | 55 | def _order_photos(col, auth_user, photo_id, per_page) 56 | repository.adapter.select('SELECT ' + col + ' FROM ( 57 | SELECT (@row_num := @row_num + 1) AS row_num, FLOOR((@row_num-1) / ?) + 1 AS page_num, id 58 | FROM ( 59 | SELECT photos.id, photos.date_uploaded 60 | FROM `photos` 61 | JOIN (SELECT @row_num := 0) r 62 | INNER JOIN `photo_tags` ON `photos`.`id` = `photo_tags`.`photo_id` 63 | INNER JOIN `tags` ON `photo_tags`.`tag_id` = `tags`.`id` 64 | WHERE `photo_tags`.`tag_id` = ? 65 | ' + (auth_user && auth_user.id == self.user_id ? '' : 'AND `photos`.`public` = 1') + ' 66 | GROUP BY `photos`.`id` 67 | ORDER BY `photos`.`date_uploaded` DESC 68 | ) AS photo_list 69 | ) AS tmp 70 | WHERE id = ? 71 | ', per_page, self.id, photo_id)[0] 72 | end 73 | 74 | def get_all_dates 75 | years = repository.adapter.select(' 76 | SELECT YEAR(date_taken) AS year, COUNT(1) AS num 77 | FROM photos 78 | JOIN photo_tags lk ON photos.id = lk.photo_id 79 | WHERE lk.tag_id = ? 80 | GROUP BY year 81 | ORDER BY year DESC 82 | ', self.id) 83 | months = repository.adapter.select(' 84 | SELECT YEAR(date_taken) AS year, MONTH(date_taken) AS month, COUNT(1) AS num 85 | FROM photos 86 | JOIN photo_tags lk ON photos.id = lk.photo_id 87 | WHERE lk.tag_id = ? 88 | GROUP BY year, month 89 | ORDER BY year DESC, month DESC 90 | ', self.id) 91 | days = [] 92 | # days = repository.adapter.select(' 93 | # SELECT YEAR(date_taken) AS year, MONTH(date_taken) AS month, DAY(date_taken) AS day 94 | # FROM photos 95 | # JOIN photo_tags lk ON photos.id = lk.photo_id 96 | # WHERE lk.tag_id = ? 97 | # GROUP BY year, month, day 98 | # ORDER BY year DESC, month DESC, day DESC 99 | # ', self.id) 100 | {:years => years, :months => months, :days => days} 101 | end 102 | 103 | def self.create_from_flickr(obj, user) 104 | tag = Tag.new 105 | tag.user = user 106 | tag.machine_tag = obj.machine_tag 107 | tag.tag = obj._content 108 | tag.name = obj.raw 109 | tag 110 | end 111 | 112 | def update_count! 113 | self.num = PhotoTag.count :tag_id => self.id 114 | self.save 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /models/person.rb: -------------------------------------------------------------------------------- 1 | class Person 2 | include DataMapper::Resource 3 | property :id, Serial 4 | belongs_to :user 5 | has n, :photos, :through => :person_photo 6 | 7 | property :nsid, String, :length => 50, :index => true 8 | property :username, String, :length => 100 9 | property :realname, String, :length => 100 10 | 11 | property :num, Integer, :default => 0 12 | 13 | include FlickrArchivr::PhotoList 14 | 15 | def display_name 16 | (self.realname && (!self.realname.empty?) ? self.realname : self.username) 17 | end 18 | 19 | def list_type 20 | 'person' 21 | end 22 | 23 | # Returns the relative link to this item's page on this website 24 | def page(photo=nil) 25 | "/#{self.username_from_id(self.user_id)}/person/#{self.id}/#{self.title_urlsafe}" + (photo.nil? ? "" : "?show=#{photo.id}") 26 | end 27 | 28 | def title_urlsafe 29 | if self.username 30 | self.username.gsub(/[^A-Za-z0-9_-]/, '-').gsub(/-+/, '-') 31 | else 32 | self.realname.gsub(/[^A-Za-z0-9_-]/, '-').gsub(/-+/, '-') 33 | end 34 | end 35 | 36 | def verify_permission!(user, auth_user) 37 | raise FlickrArchivr::NotFoundError if self.user_id != user.id 38 | true 39 | end 40 | 41 | def cover_photo 42 | puts "Retrieving cover photo for #{self.display_name}" 43 | self.get_photos(nil, 1, 1)[0] 44 | end 45 | 46 | # Return the next and previous n photos given this ordering 47 | def get_context(auth_user, photo_id, num) 48 | row_num = self.row_for_photo auth_user, photo_id 49 | repository.adapter.select(' 50 | SELECT row_num, id, title, local_path_sq FROM ( 51 | SELECT (@row_num := @row_num + 1) AS row_num, id, title, local_path_sq 52 | FROM ( 53 | SELECT photos.id, photos.date_uploaded, photos.title, local_path_sq 54 | FROM `photos` 55 | JOIN (SELECT @row_num := 0) r 56 | INNER JOIN `person_photos` ON `photos`.`id` = `person_photos`.`photo_id` 57 | INNER JOIN `people` ON `person_photos`.`person_id` = `people`.`id` 58 | WHERE `person_photos`.`person_id` = ? 59 | ' + (auth_user && auth_user.id == self.user_id ? '' : 'AND `photos`.`public` = 1') + ' 60 | GROUP BY `photos`.`id` 61 | ORDER BY `photos`.`date_uploaded` DESC 62 | ) AS photo_list 63 | ) AS tmp 64 | WHERE row_num >= ? - ? AND row_num <= ? + ? 65 | ', self.id, row_num, num, row_num, num) 66 | end 67 | 68 | def _order_photos(col, auth_user, photo_id, per_page) 69 | repository.adapter.select('SELECT ' + col + ' FROM ( 70 | SELECT (@row_num := @row_num + 1) AS row_num, FLOOR((@row_num-1) / ?) + 1 AS page_num, id 71 | FROM ( 72 | SELECT photos.id, photos.date_uploaded 73 | FROM `photos` 74 | JOIN (SELECT @row_num := 0) r 75 | INNER JOIN `person_photos` ON `photos`.`id` = `person_photos`.`photo_id` 76 | INNER JOIN `people` ON `person_photos`.`person_id` = `people`.`id` 77 | WHERE `person_photos`.`person_id` = ? 78 | ' + (auth_user && auth_user.id == self.user_id ? '' : 'AND `photos`.`public` = 1') + ' 79 | GROUP BY `photos`.`id` 80 | ORDER BY `photos`.`date_uploaded` DESC 81 | ) AS photo_list 82 | ) AS tmp 83 | WHERE id = ? 84 | ', per_page, self.id, photo_id)[0] 85 | end 86 | 87 | def get_all_dates 88 | years = repository.adapter.select(' 89 | SELECT YEAR(date_taken) AS year, COUNT(1) AS num 90 | FROM photos 91 | JOIN person_photos lk ON photos.id = lk.photo_id 92 | WHERE lk.person_id = ? 93 | GROUP BY year 94 | ORDER BY year DESC 95 | ', self.id) 96 | months = repository.adapter.select(' 97 | SELECT YEAR(date_taken) AS year, MONTH(date_taken) AS month, COUNT(1) AS num 98 | FROM photos 99 | JOIN person_photos lk ON photos.id = lk.photo_id 100 | WHERE lk.person_id = ? 101 | GROUP BY year, month 102 | ORDER BY year DESC, month DESC 103 | ', self.id) 104 | days = [] 105 | # days = repository.adapter.select(' 106 | # SELECT YEAR(date_taken) AS year, MONTH(date_taken) AS month, DAY(date_taken) AS day 107 | # FROM photos 108 | # JOIN person_photos lk ON photos.id = lk.photo_id 109 | # WHERE lk.person_id = ? 110 | # GROUP BY year, month, day 111 | # ORDER BY year DESC, month DESC, day DESC 112 | # ', self.id) 113 | {:years => years, :months => months, :days => days} 114 | end 115 | 116 | def self.create_from_flickr(obj, user) 117 | person = Person.new 118 | person.user = user 119 | person.nsid = obj.nsid 120 | person.username = obj.username 121 | person.realname = obj.realname 122 | person 123 | end 124 | 125 | def update_count! 126 | self.num = PersonPhoto.count :person_id => self.id 127 | self.save 128 | end 129 | end -------------------------------------------------------------------------------- /views/layout.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Archivr 6 | 7 | 8 | 9 | 10 | <% if SiteConfig.ga_id %> 11 | 22 | <% end %> 23 | 24 | 25 | 26 |
27 |
28 |
29 |

<%= (@user ? @user.username : 'Archivr') %>

30 | 31 | <% if @user %> 32 | 44 | 45 | 57 | 58 | 70 | <% end #if looking at a user's photos %> 71 | 72 | <% if @me %> 73 | 86 | <% else %> 87 | 90 | <% end #if logged in %> 91 | 92 | <% if @user %> 93 |
94 | 95 |
96 | <% end %> 97 | 98 |
99 |
100 |
101 | 102 | <%= yield %> 103 | 104 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | -------------------------------------------------------------------------------- /public/css/site.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding-top: 60px; 3 | } 4 | 5 | .page-title { 6 | margin-bottom: 10px; 7 | } 8 | 9 | .footer { 10 | background-color: #EEE; 11 | padding: 30px 0; 12 | text-shadow: 0 1px 0 white; 13 | border-top: 1px solid #E5E5E5; 14 | } 15 | .footer .container { 16 | color: #777; 17 | font-size: 9pt; 18 | } 19 | .footer a { 20 | text-decoration: underline; 21 | color: #666; 22 | } 23 | 24 | /** 25 | * Sidebar 26 | */ 27 | 28 | .sidebar-widget { 29 | margin-top: 10px; 30 | clear: both; 31 | } 32 | 33 | .sidebar-widget h3 { 34 | border-bottom: 1px #c8c8c8 solid; 35 | line-height: 1.3em; 36 | } 37 | 38 | .sidebar-widget ul { 39 | margin-top: 4px; 40 | margin-bottom: 4px; 41 | } 42 | 43 | .sidebar-widget ul.tags { 44 | list-style-type: none; 45 | margin: 0; 46 | padding: 0; 47 | } 48 | .sidebar-widget .tags li { 49 | float: left; 50 | margin: 4px 0 0 0; 51 | padding: 0 20px 0 0; 52 | } 53 | 54 | .sidebar-widget .small-link { 55 | float: right; 56 | font-size: 13px; 57 | font-weight: normal; 58 | } 59 | 60 | .sidebar-widget.related .num { 61 | color: #aaa; 62 | font-size: 0.9em; 63 | } 64 | 65 | /** 66 | * Photo Privacy 67 | */ 68 | 69 | .photos .private a { 70 | background-color: #EF9999; 71 | border-color: #E45757; 72 | } 73 | 74 | .photos .family a, .photos .friends a { 75 | background-color: #FBDC7B; 76 | border-color: #FACD27; 77 | } 78 | 79 | .photos .public a { 80 | 81 | } 82 | 83 | /** 84 | * Photo Map 85 | */ 86 | .photo_map { 87 | position: relative; 88 | height: 180px; 89 | width: 280px; 90 | display: block; 91 | border: 1px #C8C8C8 solid; 92 | } 93 | .photo_map .map { 94 | position: absolute; 95 | top: 0; 96 | left: 0; 97 | } 98 | .photo_map #photo_map_far { 99 | } 100 | .photo_map #photo_map_near { 101 | display: none; 102 | } 103 | 104 | 105 | /** 106 | * Pagination 107 | */ 108 | .pagination .next a { 109 | border-right: 1px solid rgba(0, 0, 0, 0.15); 110 | } 111 | .pagination .last a { 112 | border: 0; 113 | } 114 | .pagination .more span { 115 | border-right: 1px solid rgba(0, 0, 0, 0.15); 116 | float: left; 117 | line-height: 34px; 118 | padding: 0 4px; 119 | } 120 | 121 | /** 122 | * Size Dropdown 123 | */ 124 | .photo-sizes .dropdown { 125 | width: 26px; 126 | } 127 | .photo-sizes .dropdown-toggle.closed:after { 128 | vertical-align: top; 129 | margin-top: 4px; 130 | border-bottom: 4px solid transparent; 131 | border-top: 4px solid transparent; 132 | border-left: 4px solid black; 133 | border-right: none; 134 | } 135 | .photo-sizes .dropdown-toggle.open:after { 136 | vertical-align: top; 137 | margin-top: 7px; 138 | border-left: 4px solid transparent; 139 | border-right: 4px solid transparent; 140 | border-top: 4px solid black; 141 | } 142 | .photo-sizes .hidden { 143 | display: none; 144 | } 145 | .condensed-table.photo-sizes td { 146 | padding: 3px 2px; 147 | } 148 | .photo-sizes tr.o td { 149 | border-top: none; 150 | } 151 | .photo-sizes .dimensions { 152 | font-size: 85%; 153 | } 154 | 155 | /** 156 | * List of sets 157 | */ 158 | .sets:after { 159 | clear: both; 160 | } 161 | .sets { 162 | display: table; 163 | margin: 0; 164 | padding: 0; 165 | } 166 | .sets .set { 167 | display: inline-block; 168 | margin: 0 30px 0 0; 169 | padding: 0; 170 | vertical-align: top; 171 | } 172 | .sets .set-in.s { 173 | width: 252px; 174 | } 175 | .sets .set-in.sq { 176 | width: 123px; 177 | } 178 | .sets .set-thumbnail { 179 | border: 1px solid #DDDDDD; 180 | border-radius: 4px 4px 4px 4px; 181 | box-shadow: 0 1px 1px rgba(0, 0, 0, 0.075); 182 | padding: 4px; 183 | float: left; 184 | } 185 | .sets .set-thumbnail img { 186 | display: block; 187 | border: none; 188 | } 189 | .sets .set h4 { 190 | clear: both; 191 | line-height: 19px; 192 | padding-top: 8px; 193 | } 194 | .sets .set p { 195 | color: #777; 196 | font-size: 11px; 197 | } 198 | .sets .set p .num { 199 | font-weight: bold; 200 | } 201 | 202 | /** 203 | * Photo context 204 | */ 205 | .sidebar-widget h5 { 206 | padding-bottom: 0; 207 | margin-bottom: 0; 208 | margin-top: 3px; 209 | line-height: 18px; 210 | } 211 | .context-table td { 212 | padding: 2px; 213 | border: 0; 214 | } 215 | .context-table td a { 216 | padding: 1px; 217 | float: left; 218 | border: 1px solid #DDDDDD; 219 | border-radius: 2px 2px 2px 2px; 220 | box-shadow: 0 1px 1px rgba(0, 0, 0, 0.075); 221 | } 222 | .context-table td.selected a { 223 | border: 1px solid #777; 224 | border-radius: 2px 2px 2px 2px; 225 | box-shadow: 0 1px 1px rgba(0, 0, 0, 0.075); 226 | } 227 | .context-table td a img { 228 | display: block; 229 | border: none; 230 | } 231 | 232 | 233 | /** 234 | * List of tags 235 | */ 236 | .tags ul { 237 | list-style-type: none; 238 | margin: 0; 239 | padding: 0; 240 | } 241 | .tags ul li { 242 | margin: 0 10px 4px 0; 243 | padding: 0; 244 | float: left; 245 | } 246 | .tags ul { 247 | font-size: 14px; 248 | } 249 | .tags .weight-0 { 250 | font-size: 100%; 251 | } 252 | .tags .weight-1 { 253 | font-size: 105%; 254 | } 255 | .tags .weight-2 { 256 | font-size: 110%; 257 | } 258 | .tags .weight-3 { 259 | font-size: 115%; 260 | } 261 | .tags .weight-4 { 262 | font-size: 120%; 263 | } 264 | .tags .weight-5 { 265 | font-size: 130%; 266 | } 267 | .tags .weight-6 { 268 | font-size: 140%; 269 | } 270 | .tags .weight-7 { 271 | font-size: 150%; 272 | } 273 | .tags .weight-8 { 274 | font-size: 160%; 275 | } 276 | .tags .weight-9 { 277 | font-size: 170%; 278 | } 279 | .tags .weight-10 { 280 | font-size: 180%; 281 | } 282 | -------------------------------------------------------------------------------- /models/user.rb: -------------------------------------------------------------------------------- 1 | class User 2 | include DataMapper::Resource 3 | property :id, Serial 4 | 5 | property :nsid, String, :length => 50, :index => true 6 | property :username, String, :length => 100 7 | 8 | property :import_timestamp, Integer, :default => 0 9 | property :last_photo_imported, String, :length => 50 10 | 11 | property :access_token, String, :length => 255 12 | property :access_secret, String, :length => 255 13 | 14 | property :created_at, DateTime 15 | property :updated_at, DateTime 16 | 17 | has n, :photos 18 | has n, :places 19 | has n, :tags 20 | has n, :photosets 21 | has n, :people 22 | 23 | include FlickrArchivr::PhotoList 24 | 25 | def display_name 26 | self.username 27 | end 28 | 29 | def list_type 30 | 'user' 31 | end 32 | 33 | def get_sets(auth_user, page, per_page) 34 | if auth_user && auth_user.id == self.id 35 | self.photosets.all(:order => [:sequence.asc, :updated_date.desc, :created_date.desc, :id.desc]).page(page || 1, :per_page => per_page) 36 | else 37 | self.photosets.all(:is_public => true, :order => [:sequence.asc, :updated_date.desc, :created_date.desc, :id.desc]).page(page || 1, :per_page => per_page) 38 | end 39 | end 40 | 41 | def get_tags(auth_user, page, per_page) 42 | self.tags.all(:order => [:num.desc, :updated_at.desc, :created_at.desc, :id.desc]).page(page || 1, :per_page => per_page) 43 | end 44 | 45 | def get_people(auth_user, page, per_page) 46 | self.people.all(:order => [:num.desc, :id.desc]).page(page || 1, :per_page => per_page) 47 | end 48 | 49 | def get_popular_tags(auth_user) 50 | self.tags.all(:order => [:num.desc, :updated_at.desc, :created_at.desc, :id.desc]).page(1, :per_page => 150).all.sort! {|a,b| a.name.downcase <=> b.name.downcase} 51 | end 52 | 53 | def page(photo=nil) 54 | "/#{self.username}" + (photo.nil? ? "" : "?show=#{photo.id}") 55 | end 56 | 57 | # Return the next and previous n photos given this ordering 58 | def get_context(auth_user, photo_id, num) 59 | row_num = self.row_for_photo auth_user, photo_id 60 | repository.adapter.select(' 61 | SELECT row_num, id, title, local_path_sq FROM ( 62 | SELECT (@row_num := @row_num + 1) AS row_num, id, title, local_path_sq 63 | FROM ( 64 | SELECT photos.id, photos.date_uploaded, photos.title, local_path_sq 65 | FROM `photos` 66 | JOIN (SELECT @row_num := 0) r 67 | WHERE `photos`.`user_id` = ? 68 | ' + (auth_user && auth_user.id == self.id ? '' : 'AND `photos`.`public` = 1') + ' 69 | ORDER BY `photos`.`date_uploaded` DESC 70 | ) AS photo_list 71 | ) AS tmp 72 | WHERE row_num >= ? - ? AND row_num <= ? + ? 73 | ', self.id, row_num, num, row_num, num) 74 | end 75 | 76 | def _order_photos(col, auth_user, photo_id, per_page) 77 | repository.adapter.select('SELECT ' + col + ' FROM ( 78 | SELECT (@row_num := @row_num + 1) AS row_num, FLOOR((@row_num-1) / ?) + 1 AS page_num, id 79 | FROM ( 80 | SELECT photos.id, photos.date_uploaded 81 | FROM `photos` 82 | JOIN (SELECT @row_num := 0) r 83 | WHERE `photos`.`user_id` = ? 84 | ' + (auth_user && auth_user.id == self.id ? '' : 'AND `photos`.`public` = 1') + ' 85 | ORDER BY `photos`.`date_uploaded` DESC 86 | ) AS photo_list 87 | ) AS tmp 88 | WHERE id = ? 89 | ', per_page, self.id, photo_id)[0] 90 | end 91 | 92 | def get_related_dates(year, month=nil, day=nil) 93 | years = [] 94 | months = [] 95 | days = [] 96 | 97 | if year && month && day 98 | years = repository.adapter.select(' 99 | SELECT YEAR(date_taken) AS year, COUNT(1) AS num 100 | FROM photos 101 | WHERE user_id = ? 102 | AND YEAR(date_taken) = ? 103 | GROUP BY year 104 | ORDER BY year DESC 105 | ', self.id, year) 106 | months = repository.adapter.select(' 107 | SELECT YEAR(date_taken) AS year, MONTH(date_taken) AS month, COUNT(1) AS num 108 | FROM photos 109 | WHERE user_id = ? 110 | AND YEAR(date_taken) = ? 111 | AND MONTH(date_taken) = ? 112 | GROUP BY year, month 113 | ORDER BY year DESC, month DESC 114 | ', self.id, year, month) 115 | elsif year && month 116 | years = repository.adapter.select(' 117 | SELECT YEAR(date_taken) AS year, COUNT(1) AS num 118 | FROM photos 119 | WHERE user_id = ? 120 | AND YEAR(date_taken) = ? 121 | GROUP BY year 122 | ORDER BY year DESC 123 | ', self.id, year) 124 | days = repository.adapter.select(' 125 | SELECT YEAR(date_taken) AS year, MONTH(date_taken) AS month, DAY(date_taken) AS day, COUNT(1) AS num 126 | FROM photos 127 | WHERE user_id = ? 128 | AND YEAR(date_taken) = ? 129 | AND MONTH(date_taken) = ? 130 | GROUP BY year, month, day 131 | ORDER BY year DESC, month DESC, day DESC 132 | ', self.id, year, month) 133 | else 134 | years = repository.adapter.select(' 135 | SELECT YEAR(date_taken) AS year, COUNT(1) AS num 136 | FROM photos 137 | WHERE user_id = ? 138 | AND YEAR(date_taken) = ? 139 | GROUP BY year 140 | ORDER BY year DESC 141 | ', self.id, year) 142 | months = repository.adapter.select(' 143 | SELECT YEAR(date_taken) AS year, MONTH(date_taken) AS month, COUNT(1) AS num 144 | FROM photos 145 | WHERE user_id = ? 146 | AND YEAR(date_taken) = ? 147 | GROUP BY year, month 148 | ORDER BY year DESC, month DESC 149 | ', self.id, year) 150 | end 151 | {:years => years, :months => months, :days => days} 152 | end 153 | 154 | end -------------------------------------------------------------------------------- /views/photos/view.erb: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 5 |
6 | 7 |

<%== @photo.title %>

8 | 9 |

<%= @photo.img_tag 'z' %>

10 |

<%= format_text @photo.description %>

11 | 12 |
13 |
14 | 15 | 23 | 24 | <% if @has_location %> 25 | 40 | <% end %> 41 | 42 | 66 | 67 | <% if @people.count > 0 %> 68 | 83 | <% end %> 84 | 85 | <% if @photosets.count > 0 %> 86 | 101 | <% end %> 102 | 103 | <% if @photo_tags.count > 0 %> 104 | 120 | <% end %> 121 | 122 | <% if @machine_tags.count > 0 %> 123 | 131 | <% end %> 132 | 133 |
134 | 135 |
136 | 137 |
-------------------------------------------------------------------------------- /models/photoset.rb: -------------------------------------------------------------------------------- 1 | class Photoset 2 | include DataMapper::Resource 3 | property :id, Serial 4 | 5 | belongs_to :user 6 | has n, :photos, :through => Resource 7 | 8 | property :flickr_id, String, :length => 50, :index => true 9 | property :is_public, Boolean, :default => true 10 | 11 | property :title, String, :length => 255 12 | property :description, Text 13 | property :primary_flickr_id, String, :length => 50 # The Flickr id of the primary photo 14 | property :secret, String, :length => 50 15 | 16 | property :num, Integer, :default => 0 17 | property :flickr_views, Integer, :default => 0 18 | property :flickr_comments, Integer, :default => 0 19 | property :num_photos, Integer, :default => 0 20 | property :num_videos, Integer, :default => 0 21 | 22 | property :created_date, DateTime # from Flickr 23 | property :updated_date, DateTime # from Flickr 24 | 25 | property :sequence, Integer, :default => 0 26 | property :raw, Text 27 | 28 | include FlickrArchivr::PhotoList 29 | 30 | def display_name 31 | self.title 32 | end 33 | 34 | def list_type 35 | 'set' 36 | end 37 | 38 | # Returns the relative link to this item's page on this website 39 | def page(photo=nil) 40 | "/#{self.username_from_id(self.user_id)}/set/#{self.id}/#{self.title_urlsafe}" + (photo.nil? ? "" : "?show=#{photo.id}") 41 | end 42 | 43 | def title_urlsafe 44 | self.title.gsub(/[^A-Za-z0-9_-]/, '-') 45 | end 46 | 47 | def verify_permission!(user, auth_user) 48 | raise FlickrArchivr::NotFoundError if self.user_id != user.id 49 | if auth_user 50 | # Disallow if the photo is not public and the authenticated user does not own the photo 51 | raise FlickrArchivr::ForbiddenError if !self.is_public && self.user_id != auth_user.id 52 | else 53 | # Disallow if the photo is not public 54 | raise FlickrArchivr::ForbiddenError if !self.is_public 55 | end 56 | true 57 | end 58 | 59 | def cover_photo 60 | cover = nil 61 | if self.primary_flickr_id 62 | cover = Photo.first :flickr_id => self.primary_flickr_id, :user => self.user 63 | end 64 | if cover.nil? 65 | cover = self.get_photos(nil, 1, 1)[0] 66 | end 67 | cover 68 | end 69 | 70 | # Return the next and previous n photos given this ordering 71 | def get_context(auth_user, photo_id, num) 72 | row_num = self.row_for_photo auth_user, photo_id 73 | repository.adapter.select(' 74 | SELECT row_num, id, title, local_path_sq FROM ( 75 | SELECT (@row_num := @row_num + 1) AS row_num, id, title, local_path_sq 76 | FROM ( 77 | SELECT photos.id, photos.date_uploaded, photos.title, local_path_sq 78 | FROM `photos` 79 | JOIN (SELECT @row_num := 0) r 80 | INNER JOIN `photo_photosets` ON `photos`.`id` = `photo_photosets`.`photo_id` 81 | INNER JOIN `photosets` ON `photo_photosets`.`photoset_id` = `photosets`.`id` 82 | WHERE `photo_photosets`.`photoset_id` = ? 83 | ' + (auth_user && auth_user.id == self.user_id ? '' : 'AND `photos`.`public` = 1') + ' 84 | GROUP BY `photos`.`id` 85 | ORDER BY `photos`.`date_uploaded` DESC 86 | ) AS photo_list 87 | ) AS tmp 88 | WHERE row_num >= ? - ? AND row_num <= ? + ? 89 | ', self.id, row_num, num, row_num, num) 90 | end 91 | 92 | def _order_photos(col, auth_user, photo_id, per_page) 93 | repository.adapter.select('SELECT ' + col + ' FROM ( 94 | SELECT (@row_num := @row_num + 1) AS row_num, FLOOR((@row_num-1) / ?) + 1 AS page_num, id 95 | FROM ( 96 | SELECT photos.id, photos.date_uploaded 97 | FROM `photos` 98 | JOIN (SELECT @row_num := 0) r 99 | INNER JOIN `photo_photosets` ON `photos`.`id` = `photo_photosets`.`photo_id` 100 | INNER JOIN `photosets` ON `photo_photosets`.`photoset_id` = `photosets`.`id` 101 | WHERE `photo_photosets`.`photoset_id` = ? 102 | ' + (auth_user && auth_user.id == self.user_id ? '' : 'AND `photos`.`public` = 1') + ' 103 | GROUP BY `photos`.`id` 104 | ORDER BY `photos`.`date_uploaded` DESC 105 | ) AS photo_list 106 | ) AS tmp 107 | WHERE id = ? 108 | ', per_page, self.id, photo_id)[0] 109 | end 110 | 111 | def count_public_photos 112 | repository.adapter.select('SELECT SUM(public) AS public 113 | FROM photos 114 | INNER JOIN photo_photosets ON photos.id = photo_photosets.photo_id 115 | WHERE photo_photosets.photoset_id = ?', self.id)[0] 116 | end 117 | 118 | def get_all_dates 119 | years = repository.adapter.select(' 120 | SELECT YEAR(date_taken) AS year, COUNT(1) AS num 121 | FROM photos 122 | JOIN photo_photosets lk ON photos.id = lk.photo_id 123 | WHERE lk.photoset_id = ? 124 | GROUP BY year 125 | ORDER BY year DESC 126 | ', self.id) 127 | months = repository.adapter.select(' 128 | SELECT YEAR(date_taken) AS year, MONTH(date_taken) AS month, COUNT(1) AS num 129 | FROM photos 130 | JOIN photo_photosets lk ON photos.id = lk.photo_id 131 | WHERE lk.photoset_id = ? 132 | GROUP BY year, month 133 | ORDER BY year DESC, month DESC 134 | ', self.id) 135 | days = [] 136 | # days = repository.adapter.select(' 137 | # SELECT YEAR(date_taken) AS year, MONTH(date_taken) AS month, DAY(date_taken) AS day 138 | # FROM photos 139 | # JOIN photo_photosets lk ON photos.id = lk.photo_id 140 | # WHERE lk.photoset_id = ? 141 | # GROUP BY year, month, day 142 | # ORDER BY year DESC, month DESC, day DESC 143 | # ', self.id) 144 | {:years => years, :months => months, :days => days} 145 | end 146 | 147 | def self.create_from_flickr(obj, user) 148 | set = Photoset.new 149 | set.user = user 150 | set.flickr_id = obj.id 151 | set.title = obj.title 152 | set.primary_flickr_id = obj.primary 153 | set.secret = obj.secret 154 | set.description = obj.description if obj.respond_to?('description') 155 | set.num_photos = obj.count_photo if obj.respond_to?('count_photo') 156 | set.num_photos = obj.photos if obj.respond_to?('photos') 157 | set.num_videos = obj.count_video if obj.respond_to?('count_video') 158 | set.num_videos = obj.videos if obj.respond_to?('videos') 159 | set.flickr_views = obj.view_count if obj.respond_to?('view_count') 160 | set.flickr_views = obj.count_views if obj.respond_to?('count_views') 161 | set.flickr_comments = obj.comment_count if obj.respond_to?('comment_count') 162 | set.flickr_comments = obj.count_comments if obj.respond_to?('count_comments') 163 | set.created_date = Time.at(obj.date_create.to_i) if obj.respond_to?('date_create') 164 | set.updated_date = Time.at(obj.date_update.to_i) if obj.respond_to?('date_update') 165 | set.raw = obj.to_hash.to_json if obj.respond_to?('count_views') 166 | set 167 | end 168 | 169 | def update_from_flickr(obj) 170 | self.flickr_id = obj.id 171 | self.title = obj.title 172 | self.primary_flickr_id = obj.primary 173 | self.secret = obj.secret 174 | self.description = obj.description if obj.respond_to?('description') 175 | self.num_photos = obj.count_photo if obj.respond_to?('count_photo') 176 | self.num_photos = obj.photos if obj.respond_to?('photos') 177 | self.num_videos = obj.count_video if obj.respond_to?('count_video') 178 | self.num_videos = obj.videos if obj.respond_to?('videos') 179 | self.flickr_views = obj.view_count if obj.respond_to?('view_count') 180 | self.flickr_views = obj.count_views if obj.respond_to?('count_views') 181 | self.flickr_comments = obj.comment_count if obj.respond_to?('comment_count') 182 | self.flickr_comments = obj.count_comments if obj.respond_to?('count_comments') 183 | self.created_date = Time.at(obj.date_create.to_i) if obj.respond_to?('date_create') 184 | self.updated_date = Time.at(obj.date_update.to_i) if obj.respond_to?('date_update') 185 | self.raw = obj.to_hash.to_json if obj.respond_to?('count_views') 186 | end 187 | 188 | def update_count! 189 | self.num = PhotoPhotoset.count :photoset_id => self.id 190 | self.save 191 | end 192 | end 193 | -------------------------------------------------------------------------------- /models/photo.rb: -------------------------------------------------------------------------------- 1 | class Photo 2 | include DataMapper::Resource 3 | property :id, Serial 4 | 5 | belongs_to :user 6 | belongs_to :owner, 'Person' 7 | has n, :tags, :through => Resource 8 | has n, :photosets, :through => Resource 9 | has n, :places, :through => Resource 10 | has n, :people, :through => :person_photo 11 | 12 | property :flickr_id, String, :length => 50, :index => true 13 | property :username, String, :length => 100 # Username of the photo's owner. Used to avoid a DB lookup when creating links to the photo 14 | 15 | property :title, String, :length => 512 16 | property :description, Text 17 | property :date_taken, DateTime 18 | property :date_uploaded, DateTime 19 | property :last_update, DateTime 20 | 21 | property :media, String, :length => 50, :default => "photo", :index => true 22 | property :format, String, :length => 10, :default => "jpg" 23 | 24 | property :latitude, Float 25 | property :longitude, Float 26 | property :accuracy, Float 27 | 28 | property :public, Boolean 29 | property :friends, Boolean 30 | property :family, Boolean 31 | 32 | property :geo_public, Boolean 33 | property :geo_friend, Boolean 34 | property :geo_family, Boolean 35 | property :geo_contact, Boolean 36 | 37 | property :url, String, :length => 255 38 | 39 | property :url_sq, String, :length => 255 40 | property :local_path_sq, String, :length => 512 41 | property :width_sq, Integer 42 | property :height_sq, Integer 43 | 44 | property :url_t, String, :length => 255 45 | property :local_path_t, String, :length => 512 46 | property :width_t, Integer 47 | property :height_t, Integer 48 | 49 | property :url_s, String, :length => 255 50 | property :local_path_s, String, :length => 512 51 | property :width_s, Integer 52 | property :height_s, Integer 53 | 54 | property :url_m, String, :length => 255 55 | property :local_path_m, String, :length => 512 56 | property :width_m, Integer 57 | property :height_m, Integer 58 | 59 | property :url_z, String, :length => 255 60 | property :local_path_z, String, :length => 512 61 | property :width_z, Integer 62 | property :height_z, Integer 63 | 64 | property :url_l, String, :length => 255 65 | property :local_path_l, String, :length => 512 66 | property :width_l, Integer 67 | property :height_l, Integer 68 | 69 | property :url_o, String, :length => 255 70 | property :local_path_o, String, :length => 512 71 | property :width_o, Integer 72 | property :height_o, Integer 73 | 74 | property :local_path_v, String, :length => 512 75 | 76 | property :secret, String, :length => 20 77 | property :original_secret, String, :length => 20 78 | property :raw, Text 79 | 80 | def get_class 81 | klass = "public" 82 | klass = "private" if !self.public && !self.friends && !self.family 83 | klass = "friend" if self.friends 84 | klass = "family" if self.family 85 | klass = "family friend" if self.family && self.friends 86 | klass 87 | end 88 | 89 | # Returns the relative path for the photo at the requested size. 90 | # This path is safe for URLs as well as filesystem access 91 | def path(size) 92 | self.username + '/' + self.date_taken.strftime('%Y/%m/%d/') + size + '/' 93 | end 94 | 95 | # Returns just the filename portion for the photo. This will be 96 | # appended to URL and filesystem paths. 97 | def filename(size) 98 | if size == 'v' 99 | secret = self.original_secret 100 | ext = 'mp4' 101 | elsif size == 'o' 102 | secret = self.original_secret 103 | ext = self.format 104 | else 105 | secret = self.secret 106 | ext = 'jpg'; 107 | end 108 | self.flickr_id + '_' + secret + '_' + self.filename_from_title + ".#{ext}" 109 | end 110 | 111 | # Returns the absolute path to the folder containing the jpg 112 | def abs_path(size) 113 | SiteConfig.photo_root + self.path(size) 114 | end 115 | 116 | # Returns the absolute filename to the jpg 117 | def abs_filename(size) 118 | self.abs_path(size) + self.filename(size) 119 | end 120 | 121 | # Returns the full URL to the jpg 122 | def full_url(size) 123 | SiteConfig.photo_url_root + self.path(size) + self.filename(size) 124 | end 125 | 126 | # Generate a URL-and-filesystem-safe filename given the photo title. 127 | # Remove trailing file extension, and remove all non-basic characters. 128 | def filename_from_title 129 | Photo.filename_from_title self.title 130 | end 131 | 132 | def self.filename_from_title(title) 133 | title.sub(/\.(jpg|png|gif)$/i, '').gsub(/[^A-Za-z0-9_-]/, '-').gsub(/-+/, '-').sub(/-$/, '')[0,350] 134 | end 135 | 136 | # Returns the relative link to this photo's page on this website 137 | def page(list=nil) 138 | Photo.page self.username, self.id, self.filename_from_title, list 139 | end 140 | 141 | def self.page(username, id, title, list=nil) 142 | "/#{username}/photo/#{id}/#{Photo.filename_from_title(title)}" + (list ? "?#{list.list_type}=#{list.id}" : "") 143 | end 144 | 145 | # Return attributes for width and height for inserting into an tag 146 | def wh_attr(size) 147 | if self.send('width_'+size) 148 | "width=\"#{self.send('width_'+size)}\" height=\"#{self.send('height_'+size)}\"" 149 | else 150 | '' 151 | end 152 | end 153 | 154 | def width(size) 155 | self.send("width_#{size}") 156 | end 157 | 158 | def height(size) 159 | self.send("height_#{size}") 160 | end 161 | 162 | # Return a complete image tag for the best version of the photo that fits within the requested size 163 | def img_tag(size) 164 | # Iterate through self.sizes backwards starting from #{size} 165 | # Find the first local path 166 | found_first_size = false 167 | img = '' 168 | actual_size = size 169 | Photo.sizes.reverse.each do |s| 170 | next if found_first_size == false && s != size 171 | next if img != '' 172 | found_first_size = true 173 | path = self.send('local_path_'+s) 174 | if !path.nil? 175 | img = path 176 | actual_size = s 177 | end 178 | end 179 | "" 180 | end 181 | 182 | # Raise an exception if the given user is not authorized to view this photo. 183 | # Check both logged-out visitors, as well as cross-user permissions. 184 | # user is the requested user, auth_user is the logged-in user 185 | def verify_permission!(user, auth_user) 186 | raise FlickrArchivr::NotFoundError if self.user_id != user.id 187 | if auth_user 188 | # Disallow if the photo is not public and the authenticated user does not own the photo 189 | raise FlickrArchivr::ForbiddenError if !self.public && self.user_id != auth_user.id 190 | else 191 | # Disallow if the photo is not public 192 | raise FlickrArchivr::ForbiddenError if !self.public 193 | end 194 | true 195 | end 196 | 197 | def sizes 198 | response = [] 199 | Photo.sizes.each do |s| 200 | response << s if self.send('local_path_'+s) 201 | end 202 | response 203 | end 204 | 205 | def self.sizes 206 | ['sq','t','s','m','z','l','o'] 207 | #['sq','t','s','m','z','l'] 208 | end 209 | 210 | def self.name_for_size(size) 211 | { 212 | 'sq' => 'Square', 213 | 't' => 'Tiny', 214 | 's' => 'Small', 215 | 'm' => 'Medium', 216 | 'z' => 'Medium', 217 | 'l' => 'Large', 218 | 'o' => 'Original', 219 | 'v' => 'Video' 220 | }[size] 221 | end 222 | 223 | def self.create_from_flickr(obj, user) 224 | photo = Photo.new 225 | photo.user = user 226 | photo.username = user.username 227 | photo.flickr_id = obj.id 228 | photo.title = obj.title 229 | photo.description = obj.description 230 | photo.format = obj.originalformat 231 | photo.date_taken = Time.parse obj.dates.taken 232 | photo.date_uploaded = Time.at obj.dates.posted.to_i 233 | photo.last_update = Time.at obj.dates.lastupdate.to_i if obj.dates.lastupdate 234 | if obj.respond_to?('location') 235 | photo.latitude = obj.location.latitude 236 | photo.longitude = obj.location.longitude 237 | photo.accuracy = obj.location.accuracy 238 | end 239 | photo.public = obj.visibility.ispublic 240 | photo.friends = obj.visibility.isfriend 241 | photo.family = obj.visibility.isfamily 242 | photo.secret = obj.secret 243 | photo.original_secret = obj.originalsecret 244 | photo.raw = obj.to_hash.to_json 245 | photo 246 | end 247 | 248 | def update_from_flickr(obj) 249 | self.title = obj.title 250 | self.description = obj.description 251 | self.format = obj.originalformat 252 | self.date_taken = Time.parse obj.dates.taken 253 | self.date_uploaded = Time.at obj.dates.posted.to_i 254 | self.last_update = Time.at obj.dates.lastupdate.to_i if obj.dates.lastupdate 255 | if obj.respond_to?('location') 256 | self.latitude = obj.location.latitude 257 | self.longitude = obj.location.longitude 258 | self.accuracy = obj.location.accuracy 259 | end 260 | self.public = obj.visibility.ispublic 261 | self.friends = obj.visibility.isfriend 262 | self.family = obj.visibility.isfamily 263 | self.secret = obj.secret 264 | self.original_secret = obj.originalsecret 265 | self.raw = obj.to_hash.to_json 266 | end 267 | end 268 | -------------------------------------------------------------------------------- /controllers/photos.rb: -------------------------------------------------------------------------------- 1 | 2 | ################################################################ 3 | ## Photo detail page 4 | get '/:username/photo/:id/?*' do 5 | begin 6 | load_user params[:username] 7 | @photo = Photo.first :id => params[:id] 8 | raise FlickrArchivr::NotFoundError.new if @photo.nil? 9 | @photo.verify_permission! @user, @me 10 | @photo_tags = @photo.tags.all(:machine_tag => false) 11 | @machine_tags = @photo.tags.all(:machine_tag => true) 12 | @people = @photo.people 13 | @photosets = @photo.photosets 14 | @places = @photo.places 15 | @has_location = @photo.latitude || @places.length > 0 16 | erb :'photos/view' 17 | rescue FlickrArchivr::Error => e 18 | erb :"#{e.erb_template}" 19 | end 20 | end 21 | 22 | ################################################################ 23 | ## Photo lists (user, person, tag, place, set) 24 | 25 | get '/:username/?' do 26 | begin 27 | load_user params[:username] 28 | @list = @user 29 | @title = "#{@user.username}'s Photostream" 30 | if params[:show] 31 | params[:page] = @user.page_for_photo @me, params[:show], per_page_small 32 | end 33 | @photos = @user.get_photos @me, params[:page], per_page_small 34 | 35 | load_related_photos 36 | 37 | @related_titles = { 38 | :people => 'People in these photos', 39 | :sets => 'Sets in these photos', 40 | :tags => 'Tags in these photos', 41 | :places => 'Places in these photos' 42 | } 43 | 44 | erb :'photos/list' 45 | rescue FlickrArchivr::Error => e 46 | erb :"#{e.erb_template}" 47 | end 48 | end 49 | 50 | get '/:username/person/:id/?*' do 51 | begin 52 | load_user params[:username] 53 | @list = Person.first :id => params[:id], :user => @user 54 | raise FlickrArchivr::NotFoundError.new if @list.nil? 55 | @list.verify_permission! @user, @me 56 | @title = "Photos of #{@list.display_name}" 57 | if params[:show] 58 | params[:page] = @list.page_for_photo @me, params[:show], per_page_small 59 | end 60 | @photos = @list.get_photos @me, params[:page], per_page_small 61 | 62 | load_related_photos 63 | @related_dates = @list.get_all_dates 64 | 65 | @related_titles = { 66 | :people => 'Related people', 67 | :sets => 'Sets with this person', 68 | :tags => 'Tags with this person', 69 | :places => 'Places this person appears in' 70 | } 71 | 72 | erb :'photos/list' 73 | rescue FlickrArchivr::Error => e 74 | erb :"#{e.erb_template}" 75 | end 76 | end 77 | 78 | get '/:username/set/:id/?*' do 79 | begin 80 | load_user params[:username] 81 | @list = Photoset.first :id => params[:id], :user => @user 82 | raise FlickrArchivr::NotFoundError.new if @list.nil? 83 | @list.verify_permission! @user, @me 84 | @title = @list.title 85 | if params[:show] 86 | params[:page] = @list.page_for_photo @me, params[:show], per_page_small 87 | end 88 | @photos = @list.get_photos @me, params[:page], per_page_small 89 | 90 | load_related_photos 91 | @related_dates = @list.get_all_dates 92 | 93 | @related_titles = { 94 | :people => 'People in this set', 95 | :sets => 'Related sets', 96 | :tags => 'Tags in this set', 97 | :places => 'Places in this set' 98 | } 99 | 100 | erb :'photos/list' 101 | rescue FlickrArchivr::Error => e 102 | erb :"#{e.erb_template}" 103 | end 104 | end 105 | 106 | get '/:username/place/:id/?*' do 107 | begin 108 | load_user params[:username] 109 | @list = Place.first :id => params[:id], :user => @user 110 | raise FlickrArchivr::NotFoundError.new if @list.nil? 111 | @list.verify_permission! @user, @me 112 | @title = @list.name 113 | if params[:show] 114 | params[:page] = @list.page_for_photo @me, params[:show], per_page_small 115 | end 116 | @photos = @list.get_photos @me, params[:page], per_page_small 117 | 118 | load_related_photos 119 | @related_dates = @list.get_all_dates 120 | 121 | @related_titles = { 122 | :people => 'People at this place', 123 | :sets => 'Sets at this place', 124 | :tags => 'Tags at this place', 125 | :places => 'Related Places' 126 | } 127 | 128 | erb :'photos/list' 129 | rescue FlickrArchivr::Error => e 130 | erb :"#{e.erb_template}" 131 | end 132 | end 133 | 134 | get '/:username/tag/:id/?*' do 135 | begin 136 | load_user params[:username] 137 | @list = Tag.first :id => params[:id], :user => @user 138 | raise FlickrArchivr::NotFoundError.new if @list.nil? 139 | @list.verify_permission! @user, @me 140 | @title = @list.name 141 | if params[:show] 142 | params[:page] = @list.page_for_photo @me, params[:show], per_page_small 143 | end 144 | @photos = @list.get_photos @me, params[:page], per_page_small 145 | 146 | load_related_photos 147 | @related_dates = @list.get_all_dates 148 | 149 | @related_titles = { 150 | :people => 'People with this tag', 151 | :sets => 'Sets with this tag', 152 | :tags => 'Related Tags', 153 | :places => 'Places with this tag' 154 | } 155 | 156 | erb :'photos/list' 157 | rescue FlickrArchivr::Error => e 158 | erb :"#{e.erb_template}" 159 | end 160 | end 161 | 162 | ################################################################ 163 | ## List of sets, tags, people 164 | 165 | get '/:username/sets/?' do 166 | begin 167 | load_user params[:username] 168 | @sets = @user.get_sets @me, params[:page], 3*5 169 | @page = params[:page] || 1 170 | erb :sets 171 | rescue FlickrArchivr::Error => e 172 | erb :"#{e.erb_template}" 173 | end 174 | end 175 | 176 | get '/:username/tags/?' do 177 | begin 178 | load_user params[:username] 179 | @tags = @user.get_popular_tags @me 180 | @max_photos = (@tags.to_a.max {|a,b| a.num <=> b.num}).num 181 | erb :tags 182 | rescue FlickrArchivr::Error => e 183 | erb :"#{e.erb_template}" 184 | end 185 | end 186 | 187 | get '/:username/people/?' do 188 | begin 189 | load_user params[:username] 190 | @people = @user.get_people @me, params[:page], 3*5 191 | @page = params[:page] || 1 192 | erb :people 193 | rescue FlickrArchivr::Error => e 194 | erb :"#{e.erb_template}" 195 | end 196 | end 197 | 198 | ################################################################ 199 | ## Dates 200 | 201 | #get %r{/^([a-z]+)/([0-9]{4})/([0-9]{1,2})/([0-9]{1,2})} do |username, year, month, day| 202 | get '/:username/:year/:month/:day/?' do |username, year, month, day| 203 | begin 204 | load_user username 205 | @list = @user 206 | date = DateTime.parse("#{year}-#{month}-#{day}").strftime "%B %e, %Y" 207 | @title = "Photos from #{date}" 208 | puts @title 209 | @photos = @user.get_photos_for_date @me, params[:page], per_page_small, year, month, day 210 | @photos = [] if !@photos 211 | 212 | if @photos.length > 0 213 | load_related_photos 214 | @related_dates = @list.get_related_dates year, month, day 215 | 216 | @related_titles = { 217 | :people => 'People in these photos', 218 | :sets => 'Sets in these photos', 219 | :tags => 'Tags in these photos', 220 | :places => 'Places in these photos' 221 | } 222 | end 223 | 224 | erb :'photos/list' 225 | rescue FlickrArchivr::Error => e 226 | erb :"#{e.erb_template}" 227 | end 228 | end 229 | 230 | get '/:username/:year/:month/?' do |username, year, month| 231 | #get %r{/^([a-z]+)/([0-9]{4})/([0-9]{1,2})} do |username, year, month| 232 | begin 233 | load_user username 234 | @list = @user 235 | date = DateTime.parse("#{year}-#{month}-01").strftime "%B %Y" 236 | @title = "Photos from #{date}" 237 | puts @title 238 | @photos = @user.get_photos_for_date @me, params[:page], per_page_small, year, month 239 | @photos = [] if !@photos 240 | 241 | if @photos.length > 0 242 | load_related_photos 243 | @related_dates = @list.get_related_dates year, month 244 | 245 | @related_titles = { 246 | :people => 'People in these photos', 247 | :sets => 'Sets in these photos', 248 | :tags => 'Tags in these photos', 249 | :places => 'Places in these photos' 250 | } 251 | end 252 | 253 | erb :'photos/list' 254 | rescue FlickrArchivr::Error => e 255 | erb :"#{e.erb_template}" 256 | end 257 | end 258 | 259 | get '/:username/:year/?' do |username, year| 260 | #get %r{/^([a-z]+)/([0-9]{4})} do |username, year| 261 | begin 262 | load_user username 263 | @list = @user 264 | @title = "Photos from #{year}" 265 | puts @title 266 | @photos = @user.get_photos_for_date @me, params[:page], per_page_small, year 267 | @photos = [] if !@photos 268 | 269 | if @photos.length > 0 270 | load_related_photos 271 | @related_dates = @list.get_related_dates year 272 | 273 | @related_titles = { 274 | :people => 'People in these photos', 275 | :sets => 'Sets in these photos', 276 | :tags => 'Tags in these photos', 277 | :places => 'Places in these photos' 278 | } 279 | end 280 | 281 | erb :'photos/list' 282 | rescue FlickrArchivr::Error => e 283 | erb :"#{e.erb_template}" 284 | end 285 | end 286 | 287 | ################################################################ 288 | ## Helper methods 289 | 290 | def load_related_photos 291 | @photo_ids = @photos.collect {|p| p.id} 292 | @related_sets = Photoset.all(Photoset.photos.id => @photo_ids, :order => :num.desc) 293 | @related_tags = Tag.all(Tag.photos.id => @photo_ids, :order => :num.desc) 294 | @related_people = Person.all(Person.photos.id => @photo_ids, :order => :num.desc) 295 | @related_places = Place.all(Place.photos.id => @photo_ids, :order => :num.desc) 296 | # @related_sets = PhotoPhotoset.all(:photo_id => @photo_ids, :fields => [:photoset_id], :unique => true).to_a.reject{|a| a.photoset.nil?}.sort_by!{|a| -a.photoset.num} 297 | # @related_tags = PhotoTag.all(:photo_id => @photo_ids, :fields => [:tag_id], :unique => true).to_a.reject{|a| a.tag.nil?}.sort_by!{|a| -a.tag.num} 298 | # @related_people = PersonPhoto.all(:photo_id => @photo_ids, :fields => [:person_id], :unique => true).to_a.reject{|a| a.person.nil?}.sort_by! {|a| -a.person.num} 299 | # @related_places = PhotoPlace.all(:photo_id => @photo_ids, :fields => [:place_id], :unique => true).to_a.reject{|a| a.place.nil?}.sort_by! {|a| -a.place.num} 300 | true 301 | end 302 | 303 | def format_text(text) 304 | text.gsub(/\n/, '
') 305 | end 306 | 307 | def load_user(username) 308 | @user = User.first :username => username 309 | raise FlickrArchivr::NotFoundError.new if @user.nil? 310 | end 311 | 312 | def per_page 313 | 9 * 4 314 | end 315 | 316 | def per_page_small 317 | 6 * 4 318 | end 319 | 320 | get '/:username/test/:id' do 321 | begin 322 | load_user params[:username] 323 | puts params 324 | 'TESTING' 325 | rescue FlickrArchivr::Error => e 326 | erb :"#{e.erb_template}" 327 | end 328 | end 329 | -------------------------------------------------------------------------------- /lib/flickr_import.rb: -------------------------------------------------------------------------------- 1 | class FlickrImport 2 | 3 | # Extra fields to request from the flickr API for each photo 4 | EXTRAS = 'description,license,date_upload,date_taken,owner_name,'\ 5 | 'original_format,last_update,geo,tags,machine_tags,o_dims,views,'\ 6 | 'media,path_alias,url_sq,url_t,url_s,url_m,url_z,url_l,url_o' 7 | 8 | def self.test(args) 9 | puts "Starting import for user: #{args.username}" 10 | @user = User.first :username => args.username 11 | 12 | @flickr = FlickRaw::Flickr.new 13 | @flickr.access_token = @user.access_token 14 | @flickr.access_secret = @user.access_secret 15 | 16 | mode = 'update' 17 | 18 | startTimestamp = @user.import_timestamp 19 | photosPerPage = 3 20 | 21 | if mode == 'import' 22 | photos = @flickr.people.getPhotos :user_id => "me", 23 | :per_page => photosPerPage, 24 | :max_upload_date => startTimestamp, 25 | :extras => EXTRAS 26 | else 27 | photos = @flickr.photos.recentlyUpdated :min_date => startTimestamp, 28 | :per_page => photosPerPage, 29 | :extras => EXTRAS 30 | end 31 | 32 | totalPages = photos.pages 33 | puts "====== Found #{totalPages} pages" 34 | 35 | (0..totalPages).each do |page| 36 | puts "==== Beginning page #{page}" 37 | 38 | if page > 0 39 | if mode == 'import' 40 | photos = @flickr.people.getPhotos :user_id => "me", 41 | :page => page, 42 | :per_page => photosPerPage, 43 | :max_upload_date => startTimestamp, 44 | :extras => EXTRAS 45 | else 46 | photos = @flickr.photos.recentlyUpdated :min_date => startTimestamp, 47 | :page => page, 48 | :per_page => photosPerPage, 49 | :extras => EXTRAS 50 | end 51 | end 52 | 53 | photos.each do |p| 54 | puts "#{p.title} #{p.dateupload}" 55 | end 56 | puts 57 | end 58 | 59 | puts "FINISHED!" 60 | end 61 | 62 | def self.update_counts(args) 63 | self.prepare_import args 64 | 65 | @user.people.each do |s| 66 | puts s.display_name 67 | s.update_count! 68 | end 69 | 70 | @user.photosets.each do |s| 71 | puts s.display_name 72 | s.update_count! 73 | end 74 | 75 | @user.tags.each do |s| 76 | puts s.display_name 77 | s.update_count! 78 | end 79 | 80 | @user.places.each do |s| 81 | puts s.display_name 82 | s.update_count! 83 | end 84 | end 85 | 86 | def self.do_import(args) 87 | self.process 'import', args 88 | end 89 | 90 | def self.do_update(args) 91 | self.process 'update', args 92 | end 93 | 94 | def self.process(mode, args) 95 | puts "Starting #{mode} for user: #{args.username}" 96 | self.prepare_import args 97 | 98 | if mode == 'import' && @user.import_timestamp == 0 99 | @user.import_timestamp = Time.now.to_i 100 | @user.save 101 | end 102 | 103 | startTimestamp = @user.import_timestamp 104 | updateStartedAt = DateTime.now 105 | photosPerPage = 100 106 | 107 | if mode == 'import' 108 | photos = @flickr.people.getPhotos :user_id => "me", 109 | :per_page => photosPerPage, 110 | :max_upload_date => startTimestamp, 111 | :extras => EXTRAS 112 | else 113 | photos = @flickr.photos.recentlyUpdated :min_date => startTimestamp, 114 | :per_page => photosPerPage, 115 | :extras => EXTRAS 116 | end 117 | 118 | totalPages = photos.pages 119 | puts "====== Found #{photos.total} photos in #{totalPages} pages" 120 | 121 | (1..totalPages).each do |page| 122 | puts 123 | puts "==== Beginning page #{page}" 124 | 125 | photos_added = 0 126 | 127 | if page > 1 128 | if mode == 'import' 129 | photos = @flickr.people.getPhotos :user_id => "me", 130 | :page => page, 131 | :per_page => photosPerPage, 132 | :max_upload_date => startTimestamp, 133 | :extras => EXTRAS 134 | else 135 | photos = @flickr.photos.recentlyUpdated :min_date => startTimestamp, 136 | :page => page, 137 | :per_page => photosPerPage, 138 | :extras => EXTRAS 139 | end 140 | end 141 | 142 | photos.each do |p| 143 | if photo = Photo.first(:flickr_id => p.id, :user => @user) 144 | puts "Photo #{p.id} already exists" 145 | next if mode == 'import' 146 | end 147 | 148 | photos_added += 1 149 | 150 | flickrPhoto = @flickr.photos.getInfo :photo_id => p.id, :secret => p.secret 151 | puts flickrPhoto.to_hash.to_json 152 | 153 | if photo.nil? 154 | previousSecret = nil 155 | # If an existing photo record was not found, prepare a new one 156 | photo = Photo.create_from_flickr flickrPhoto, @user 157 | else 158 | previousSecret = photo.secret 159 | # Else update the record from the flickr info 160 | photo.update_from_flickr flickrPhoto 161 | end 162 | 163 | # Determine whether to download files. During import, this is always. During updates, 164 | # only download if the secret has changed. The secret changing indicates that there is 165 | # a new jpg file (i.e. the photo was rotated or replaced) 166 | if mode == 'import' 167 | should_download = true 168 | else 169 | should_download = false 170 | should_download = true if previousSecret != photo.secret 171 | should_download = true if photo.path('sq')+photo.filename('sq') != photo.local_path_sq 172 | end 173 | 174 | if should_download 175 | photo.url = FlickRaw.url_photopage(flickrPhoto) 176 | Photo.sizes.each do |s| 177 | if p.respond_to?('url_'+s) 178 | flickrURL = p.send('url_'+s) 179 | 180 | # Store the original flickr URLs 181 | photo.send('url_'+s+'=', flickrURL) 182 | photo.send('width_'+s+'=', p.send('width_'+s)) 183 | photo.send('height_'+s+'=', p.send('height_'+s)) 184 | 185 | # Make the parent folder 186 | FileUtils.mkdir_p(photo.abs_path(s)) 187 | local_abs_filename = photo.abs_filename(s) 188 | 189 | new_local_path = photo.path(s)+photo.filename(s) 190 | 191 | # If the photo was renamed, delete the old file first 192 | old_local_path = photo.send("local_path_#{s}") 193 | if old_local_path && new_local_path != old_local_path 194 | old_abs_filename = SiteConfig.photo_root + old_local_path 195 | puts "! Deleting #{old_local_path}" 196 | `rm #{old_abs_filename}` 197 | end 198 | 199 | photo.send("local_path_#{s}=", new_local_path) 200 | 201 | # Download file from Flickr 202 | puts "Downloading #{flickrURL} to #{local_abs_filename}" 203 | `curl -o #{local_abs_filename} #{flickrURL}` 204 | puts "...done" 205 | end 206 | end # end each size 207 | 208 | # If it's a video, download the file 209 | if flickrPhoto.media == "video" 210 | FileUtils.mkdir_p(photo.abs_path('v')) 211 | local_abs_filename = photo.abs_filename('v') 212 | 213 | # The secret seems to be different for the original version of the video. Not sure how to find it 214 | # videoURL = "http://www.flickr.com/photos/#{@user.nsid}/#{photo.flickr_id}/play/orig/#{photo.secret}/" 215 | 216 | # Instead, iterate through all the available sizes and look for the largest available 217 | flickrSizes = @flickr.photos.getSizes :photo_id => p.id 218 | largestSize = 0 219 | videoURL = '' 220 | flickrSizes.size.each do |sz| 221 | if sz.media == "video" 222 | if sz.width.to_i > largestSize 223 | largestSize = sz.width.to_i 224 | videoURL = sz.source 225 | end 226 | end 227 | end 228 | 229 | puts "Downloading #{videoURL} to #{local_abs_filename}" 230 | `curl -L -o #{local_abs_filename} #{videoURL}` 231 | puts "...done" 232 | 233 | photo.local_path_v = photo.filename('v') 234 | photo.media = "video" 235 | end # end if video 236 | else 237 | puts "\twon't download file" 238 | end # end if should download 239 | 240 | owner = Person.first :nsid => flickrPhoto.owner.nsid, :user => @user 241 | if owner.nil? 242 | owner = Person.create_from_flickr flickrPhoto.owner, @user 243 | end 244 | photo.owner = owner 245 | 246 | # Tags 247 | if flickrPhoto.tags 248 | photoTags = @flickr.tags.getListPhoto :photo_id => p.id 249 | tag_ids = [] 250 | photoTags.tags.tag.each do |photoTag| 251 | tag = Tag.first :tag => photoTag._content, :user => @user 252 | if tag.nil? 253 | tag = Tag.create_from_flickr photoTag, @user 254 | end 255 | photo.tags << tag 256 | tag.num = PhotoTag.count(:tag => tag) + 1 257 | tag.save 258 | tag_ids << tag.id 259 | end 260 | 261 | # Delete any relationships for tags that were removed 262 | if photo.id && tag_ids.length > 0 263 | photo.tags.each do |t| 264 | if !tag_ids.include?(t.id) 265 | puts "Tag '#{t.name}' was removed from the photo" 266 | photo.tags.delete(t) 267 | end 268 | end 269 | end 270 | end 271 | 272 | # Sets 273 | photoContexts = @flickr.photos.getAllContexts :photo_id => p.id 274 | if photoContexts && photoContexts.respond_to?('set') 275 | set_ids = [] 276 | photoContexts.set.each do |photoSet| 277 | set = Photoset.first :flickr_id => photoSet.id, :user => @user 278 | if set.nil? 279 | set = Photoset.create_from_flickr photoSet, @user 280 | else 281 | set.update_from_flickr(photoSet) 282 | end 283 | photo.photosets << set 284 | set.save 285 | set.update_count! 286 | set_ids << set.id 287 | end 288 | 289 | # Delete any relationships for sets that were removed 290 | if photo.id && set_ids.length > 0 291 | photo.photosets.each do |s| 292 | if !set_ids.include?(s.id) 293 | puts "Set '#{s.title}' was removed from the photo" 294 | photo.photosets.delete(s) 295 | end 296 | end 297 | end 298 | end 299 | 300 | # People 301 | if flickrPhoto.respond_to?('people') && flickrPhoto.people.respond_to?('haspeople') && flickrPhoto.people.haspeople 302 | photoPeople = @flickr.photos.people.getList :photo_id => p.id 303 | people_ids = [] 304 | photoPeople.person.each do |photoPerson| 305 | person = Person.first :nsid => photoPerson.nsid, :user => @user 306 | puts photoPerson.to_hash 307 | if person.nil? 308 | person = Person.create_from_flickr photoPerson, @user 309 | end 310 | person.save 311 | people_ids << person.id 312 | if photoPerson.respond_to?('w') 313 | PersonPhoto.first_or_create({:person => person, :photo => photo}, {:w => photoPerson.w, :h => photoPerson.h, :x => photoPerson.x, :y => photoPerson.y}) 314 | else 315 | PersonPhoto.first_or_create :person => person, :photo => photo 316 | end 317 | person.update_count! 318 | end 319 | 320 | # Delete any relationships for people that were removed 321 | if photo.id && people_ids.length > 0 322 | photo.people.each do |s| 323 | if !people_ids.include?(s.id) 324 | puts "Person '#{s.username}' was removed from the photo" 325 | photo.people.delete(s) 326 | end 327 | end 328 | end 329 | end 330 | if flickrPhoto.respond_to?('people') && flickrPhoto.people.respond_to?('haspeople') && flickrPhoto.people.haspeople == 0 331 | photo.people.each do |s| 332 | puts "Person '#{s.username}' was removed" 333 | photo.people.delete(s) 334 | end 335 | end 336 | 337 | # Places 338 | begin 339 | photoPlaces = @flickr.photos.geo.getLocation :photo_id => p.id 340 | if photoPlaces && photoPlaces.respond_to?('location') 341 | location = photoPlaces.location 342 | 343 | geoPerms = @flickr.photos.geo.getPerms :photo_id => p.id 344 | photo.geo_public = geoPerms.ispublic 345 | photo.geo_friend = geoPerms.isfriend 346 | photo.geo_family = geoPerms.isfamily 347 | photo.geo_contact = geoPerms.iscontact 348 | 349 | if location.respond_to?('latitude') 350 | photo.latitude = location.latitude 351 | photo.longitude = location.longitude 352 | photo.accuracy = location.accuracy 353 | end 354 | 355 | ['neighbourhood','locality','county','region','country'].each do |type| 356 | if location.respond_to? type 357 | puts location.send(type)._content 358 | place = Place.first :type => type, :flickr_id => location.send(type).place_id, :user => @user 359 | if place.nil? 360 | place = Place.create_from_flickr type, location.send(type), @user 361 | end 362 | photo.places << place 363 | place.save 364 | place.update_count! 365 | end 366 | end 367 | end 368 | rescue FlickRaw::FailedResponse 369 | # 'flickr.photos.geo.getLocation' - Photo has no location information 370 | end 371 | 372 | # Save the photo in the database 373 | photo.save 374 | 375 | # Update the user record to reflect the timestamp of the last photo downloaded. 376 | # In 'import' mode, this relies on the photos being returned in descending order. 377 | if mode == 'import' 378 | @user.import_timestamp = photo.date_uploaded.to_time.to_i 379 | @user.last_photo_imported = p.id 380 | @user.save 381 | end 382 | 383 | end # for each photos 384 | 385 | end 386 | 387 | if mode == 'update' 388 | @user.import_timestamp = updateStartedAt.to_time.to_i 389 | @user.save 390 | end 391 | 392 | puts "FINISHED!!" 393 | 394 | end # process 395 | 396 | def self.import_sets(args) 397 | puts "Starting set import for user: #{args.username}" 398 | self.prepare_import args 399 | 400 | flickrSets = @flickr.photosets.getList :page => 1, :per_page => 100 401 | 402 | totalPages = flickrSets.pages 403 | puts "====== Found #{flickrSets.total} sets in #{totalPages} pages" 404 | 405 | sequence = 0 406 | set_ids = [] 407 | 408 | # TODO: Figure out how to tell if a set is public or private by searching the photos inside the sets. 409 | 410 | (1..totalPages).each do |page| 411 | puts 412 | puts "==== Beginning page #{page}" 413 | 414 | if page > 1 415 | flickrSets = @flickr.photosets.getList :page => page, :per_page => 100 416 | end 417 | 418 | flickrSets.each do |photoSet| 419 | puts "---- #{photoSet.title}" 420 | 421 | set_ids << photoSet.id 422 | 423 | set = Photoset.first :flickr_id => photoSet.id, :user => @user 424 | if set.nil? 425 | set = Photoset.create_from_flickr photoSet, @user 426 | else 427 | set.update_from_flickr(photoSet) 428 | end 429 | 430 | set.sequence = sequence 431 | sequence += 1 432 | 433 | set.is_public = (set.count_public_photos == 0 ? false : true) 434 | 435 | set.save() 436 | end 437 | end 438 | 439 | puts "Done importing new sets. Now looking for deleted sets..." 440 | 441 | # Loop through all sets in the DB. If there are any that are not in the list of set_ids just seen, delete them. 442 | 443 | if set_ids.length > 0 444 | @user.photosets.each do |photoset| 445 | if !set_ids.include?(photoset.flickr_id) 446 | puts "\tSet '#{photoset.title}' was deleted" 447 | photoset.destroy 448 | end 449 | end 450 | else 451 | puts "Looks like there was an error retrieving sets. Won't delete anything this time." 452 | end 453 | 454 | puts "Finished!!" 455 | end 456 | 457 | def self.prepare_import(args) 458 | @user = User.first :username => args.username 459 | 460 | if @user.nil? 461 | puts "ERROR: No user '#{args.username}'" 462 | exit! 463 | end 464 | 465 | if @user.access_token.nil? 466 | puts "ERROR: No Flickr tokens for user #{args.username}" 467 | exit! 468 | end 469 | 470 | @flickr = FlickRaw::Flickr.new 471 | @flickr.access_token = @user.access_token 472 | @flickr.access_secret = @user.access_secret 473 | 474 | begin 475 | login = @flickr.test.login 476 | puts "Flickr auth test passed #{login.username}" 477 | rescue FlickRaw::FailedResponse => e 478 | puts "ERROR: Flickr authentication failed : #{e.msg}" 479 | exit! 480 | end 481 | end 482 | 483 | end 484 | -------------------------------------------------------------------------------- /public/css/bootstrap-1.4.0.min.css: -------------------------------------------------------------------------------- 1 | html,body{margin:0;padding:0;} 2 | h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,cite,code,del,dfn,em,img,q,s,samp,small,strike,strong,sub,sup,tt,var,dd,dl,dt,li,ol,ul,fieldset,form,label,legend,button,table,caption,tbody,tfoot,thead,tr,th,td{margin:0;padding:0;border:0;font-weight:normal;font-style:normal;font-size:100%;line-height:1;font-family:inherit;} 3 | table{border-collapse:collapse;border-spacing:0;} 4 | ol,ul{list-style:none;} 5 | q:before,q:after,blockquote:before,blockquote:after{content:"";} 6 | html{overflow-y:scroll;font-size:100%;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;} 7 | a:focus{outline:thin dotted;} 8 | a:hover,a:active{outline:0;} 9 | article,aside,details,figcaption,figure,footer,header,hgroup,nav,section{display:block;} 10 | audio,canvas,video{display:inline-block;*display:inline;*zoom:1;} 11 | audio:not([controls]){display:none;} 12 | sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline;} 13 | sup{top:-0.5em;} 14 | sub{bottom:-0.25em;} 15 | img{border:0;-ms-interpolation-mode:bicubic;} 16 | button,input,select,textarea{font-size:100%;margin:0;vertical-align:baseline;*vertical-align:middle;} 17 | button,input{line-height:normal;*overflow:visible;} 18 | button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0;} 19 | button,input[type="button"],input[type="reset"],input[type="submit"]{cursor:pointer;-webkit-appearance:button;} 20 | input[type="search"]{-webkit-appearance:textfield;-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box;} 21 | input[type="search"]::-webkit-search-decoration{-webkit-appearance:none;} 22 | textarea{overflow:auto;vertical-align:top;} 23 | body{background-color:#ffffff;margin:0;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:13px;font-weight:normal;line-height:18px;color:#404040;} 24 | .container{width:940px;margin-left:auto;margin-right:auto;zoom:1;}.container:before,.container:after{display:table;content:"";zoom:1;} 25 | .container:after{clear:both;} 26 | .container-fluid{position:relative;min-width:940px;padding-left:20px;padding-right:20px;zoom:1;}.container-fluid:before,.container-fluid:after{display:table;content:"";zoom:1;} 27 | .container-fluid:after{clear:both;} 28 | .container-fluid>.sidebar{position:absolute;top:0;left:20px;width:220px;} 29 | .container-fluid>.content{margin-left:240px;} 30 | a{color:#0069d6;text-decoration:none;line-height:inherit;font-weight:inherit;}a:hover{color:#00438a;text-decoration:underline;} 31 | .pull-right{float:right;} 32 | .pull-left{float:left;} 33 | .hide{display:none;} 34 | .show{display:block;} 35 | .row{zoom:1;margin-left:-20px;}.row:before,.row:after{display:table;content:"";zoom:1;} 36 | .row:after{clear:both;} 37 | .row>[class*="span"]{display:inline;float:left;margin-left:20px;} 38 | .span1{width:40px;} 39 | .span2{width:100px;} 40 | .span3{width:160px;} 41 | .span4{width:220px;} 42 | .span5{width:280px;} 43 | .span6{width:340px;} 44 | .span7{width:400px;} 45 | .span8{width:460px;} 46 | .span9{width:520px;} 47 | .span10{width:580px;} 48 | .span11{width:640px;} 49 | .span12{width:700px;} 50 | .span13{width:760px;} 51 | .span14{width:820px;} 52 | .span15{width:880px;} 53 | .span16{width:940px;} 54 | .span17{width:1000px;} 55 | .span18{width:1060px;} 56 | .span19{width:1120px;} 57 | .span20{width:1180px;} 58 | .span21{width:1240px;} 59 | .span22{width:1300px;} 60 | .span23{width:1360px;} 61 | .span24{width:1420px;} 62 | .row>.offset1{margin-left:80px;} 63 | .row>.offset2{margin-left:140px;} 64 | .row>.offset3{margin-left:200px;} 65 | .row>.offset4{margin-left:260px;} 66 | .row>.offset5{margin-left:320px;} 67 | .row>.offset6{margin-left:380px;} 68 | .row>.offset7{margin-left:440px;} 69 | .row>.offset8{margin-left:500px;} 70 | .row>.offset9{margin-left:560px;} 71 | .row>.offset10{margin-left:620px;} 72 | .row>.offset11{margin-left:680px;} 73 | .row>.offset12{margin-left:740px;} 74 | .span-one-third{width:300px;} 75 | .span-two-thirds{width:620px;} 76 | .row>.offset-one-third{margin-left:340px;} 77 | .row>.offset-two-thirds{margin-left:660px;} 78 | p{font-size:13px;font-weight:normal;line-height:18px;margin-bottom:9px;}p small{font-size:11px;color:#bfbfbf;} 79 | h1,h2,h3,h4,h5,h6{font-weight:bold;color:#404040;}h1 small,h2 small,h3 small,h4 small,h5 small,h6 small{color:#bfbfbf;} 80 | h1{margin-bottom:18px;font-size:30px;line-height:36px;}h1 small{font-size:18px;} 81 | h2{font-size:24px;line-height:36px;}h2 small{font-size:14px;} 82 | h3,h4,h5,h6{line-height:36px;} 83 | h3{font-size:18px;}h3 small{font-size:14px;} 84 | h4{font-size:16px;}h4 small{font-size:12px;} 85 | h5{font-size:14px;} 86 | h6{font-size:13px;color:#bfbfbf;text-transform:uppercase;} 87 | ul,ol{margin:0 0 18px 25px;} 88 | ul ul,ul ol,ol ol,ol ul{margin-bottom:0;} 89 | ul{list-style:disc;} 90 | ol{list-style:decimal;} 91 | li{line-height:18px;color:#808080;} 92 | ul.unstyled{list-style:none;margin-left:0;} 93 | dl{margin-bottom:18px;}dl dt,dl dd{line-height:18px;} 94 | dl dt{font-weight:bold;} 95 | dl dd{margin-left:9px;} 96 | hr{margin:20px 0 19px;border:0;border-bottom:1px solid #eee;} 97 | strong{font-style:inherit;font-weight:bold;} 98 | em{font-style:italic;font-weight:inherit;line-height:inherit;} 99 | .muted{color:#bfbfbf;} 100 | blockquote{margin-bottom:18px;border-left:5px solid #eee;padding-left:15px;}blockquote p{font-size:14px;font-weight:300;line-height:18px;margin-bottom:0;} 101 | blockquote small{display:block;font-size:12px;font-weight:300;line-height:18px;color:#bfbfbf;}blockquote small:before{content:'\2014 \00A0';} 102 | address{display:block;line-height:18px;margin-bottom:18px;} 103 | code,pre{padding:0 3px 2px;font-family:Monaco, Andale Mono, Courier New, monospace;font-size:12px;-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px;} 104 | code{background-color:#fee9cc;color:rgba(0, 0, 0, 0.75);padding:1px 3px;} 105 | pre{background-color:#f5f5f5;display:block;padding:8.5px;margin:0 0 18px;line-height:18px;font-size:12px;border:1px solid #ccc;border:1px solid rgba(0, 0, 0, 0.15);-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px;white-space:pre;white-space:pre-wrap;word-wrap:break-word;} 106 | form{margin-bottom:18px;} 107 | fieldset{margin-bottom:18px;padding-top:18px;}fieldset legend{display:block;padding-left:150px;font-size:19.5px;line-height:1;color:#404040;*padding:0 0 5px 145px;*line-height:1.5;} 108 | form .clearfix{margin-bottom:18px;zoom:1;}form .clearfix:before,form .clearfix:after{display:table;content:"";zoom:1;} 109 | form .clearfix:after{clear:both;} 110 | label,input,select,textarea{font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:13px;font-weight:normal;line-height:normal;} 111 | label{padding-top:6px;font-size:13px;line-height:18px;float:left;width:130px;text-align:right;color:#404040;} 112 | form .input{margin-left:150px;} 113 | input[type=checkbox],input[type=radio]{cursor:pointer;} 114 | input,textarea,select,.uneditable-input{display:inline-block;width:210px;height:18px;padding:4px;font-size:13px;line-height:18px;color:#808080;border:1px solid #ccc;-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px;} 115 | select{padding:initial;} 116 | input[type=checkbox],input[type=radio]{width:auto;height:auto;padding:0;margin:3px 0;*margin-top:0;line-height:normal;border:none;} 117 | input[type=file]{background-color:#ffffff;padding:initial;border:initial;line-height:initial;-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;} 118 | input[type=button],input[type=reset],input[type=submit]{width:auto;height:auto;} 119 | select,input[type=file]{height:27px;*height:auto;line-height:27px;*margin-top:4px;} 120 | select[multiple]{height:inherit;background-color:#ffffff;} 121 | textarea{height:auto;} 122 | .uneditable-input{background-color:#ffffff;display:block;border-color:#eee;-webkit-box-shadow:inset 0 1px 2px rgba(0, 0, 0, 0.025);-moz-box-shadow:inset 0 1px 2px rgba(0, 0, 0, 0.025);box-shadow:inset 0 1px 2px rgba(0, 0, 0, 0.025);cursor:not-allowed;} 123 | :-moz-placeholder{color:#bfbfbf;} 124 | ::-webkit-input-placeholder{color:#bfbfbf;} 125 | input,textarea{-webkit-transition:border linear 0.2s,box-shadow linear 0.2s;-moz-transition:border linear 0.2s,box-shadow linear 0.2s;-ms-transition:border linear 0.2s,box-shadow linear 0.2s;-o-transition:border linear 0.2s,box-shadow linear 0.2s;transition:border linear 0.2s,box-shadow linear 0.2s;-webkit-box-shadow:inset 0 1px 3px rgba(0, 0, 0, 0.1);-moz-box-shadow:inset 0 1px 3px rgba(0, 0, 0, 0.1);box-shadow:inset 0 1px 3px rgba(0, 0, 0, 0.1);} 126 | input:focus,textarea:focus{outline:0;border-color:rgba(82, 168, 236, 0.8);-webkit-box-shadow:inset 0 1px 3px rgba(0, 0, 0, 0.1),0 0 8px rgba(82, 168, 236, 0.6);-moz-box-shadow:inset 0 1px 3px rgba(0, 0, 0, 0.1),0 0 8px rgba(82, 168, 236, 0.6);box-shadow:inset 0 1px 3px rgba(0, 0, 0, 0.1),0 0 8px rgba(82, 168, 236, 0.6);} 127 | input[type=file]:focus,input[type=checkbox]:focus,select:focus{-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;outline:1px dotted #666;} 128 | form .clearfix.error>label,form .clearfix.error .help-block,form .clearfix.error .help-inline{color:#b94a48;} 129 | form .clearfix.error input,form .clearfix.error textarea{color:#b94a48;border-color:#ee5f5b;}form .clearfix.error input:focus,form .clearfix.error textarea:focus{border-color:#e9322d;-webkit-box-shadow:0 0 6px #f8b9b7;-moz-box-shadow:0 0 6px #f8b9b7;box-shadow:0 0 6px #f8b9b7;} 130 | form .clearfix.error .input-prepend .add-on,form .clearfix.error .input-append .add-on{color:#b94a48;background-color:#fce6e6;border-color:#b94a48;} 131 | form .clearfix.warning>label,form .clearfix.warning .help-block,form .clearfix.warning .help-inline{color:#c09853;} 132 | form .clearfix.warning input,form .clearfix.warning textarea{color:#c09853;border-color:#ccae64;}form .clearfix.warning input:focus,form .clearfix.warning textarea:focus{border-color:#be9a3f;-webkit-box-shadow:0 0 6px #e5d6b1;-moz-box-shadow:0 0 6px #e5d6b1;box-shadow:0 0 6px #e5d6b1;} 133 | form .clearfix.warning .input-prepend .add-on,form .clearfix.warning .input-append .add-on{color:#c09853;background-color:#d2b877;border-color:#c09853;} 134 | form .clearfix.success>label,form .clearfix.success .help-block,form .clearfix.success .help-inline{color:#468847;} 135 | form .clearfix.success input,form .clearfix.success textarea{color:#468847;border-color:#57a957;}form .clearfix.success input:focus,form .clearfix.success textarea:focus{border-color:#458845;-webkit-box-shadow:0 0 6px #9acc9a;-moz-box-shadow:0 0 6px #9acc9a;box-shadow:0 0 6px #9acc9a;} 136 | form .clearfix.success .input-prepend .add-on,form .clearfix.success .input-append .add-on{color:#468847;background-color:#bcddbc;border-color:#468847;} 137 | .input-mini,input.mini,textarea.mini,select.mini{width:60px;} 138 | .input-small,input.small,textarea.small,select.small{width:90px;} 139 | .input-medium,input.medium,textarea.medium,select.medium{width:150px;} 140 | .input-large,input.large,textarea.large,select.large{width:210px;} 141 | .input-xlarge,input.xlarge,textarea.xlarge,select.xlarge{width:270px;} 142 | .input-xxlarge,input.xxlarge,textarea.xxlarge,select.xxlarge{width:530px;} 143 | textarea.xxlarge{overflow-y:auto;} 144 | input.span1,textarea.span1{display:inline-block;float:none;width:30px;margin-left:0;} 145 | input.span2,textarea.span2{display:inline-block;float:none;width:90px;margin-left:0;} 146 | input.span3,textarea.span3{display:inline-block;float:none;width:150px;margin-left:0;} 147 | input.span4,textarea.span4{display:inline-block;float:none;width:210px;margin-left:0;} 148 | input.span5,textarea.span5{display:inline-block;float:none;width:270px;margin-left:0;} 149 | input.span6,textarea.span6{display:inline-block;float:none;width:330px;margin-left:0;} 150 | input.span7,textarea.span7{display:inline-block;float:none;width:390px;margin-left:0;} 151 | input.span8,textarea.span8{display:inline-block;float:none;width:450px;margin-left:0;} 152 | input.span9,textarea.span9{display:inline-block;float:none;width:510px;margin-left:0;} 153 | input.span10,textarea.span10{display:inline-block;float:none;width:570px;margin-left:0;} 154 | input.span11,textarea.span11{display:inline-block;float:none;width:630px;margin-left:0;} 155 | input.span12,textarea.span12{display:inline-block;float:none;width:690px;margin-left:0;} 156 | input.span13,textarea.span13{display:inline-block;float:none;width:750px;margin-left:0;} 157 | input.span14,textarea.span14{display:inline-block;float:none;width:810px;margin-left:0;} 158 | input.span15,textarea.span15{display:inline-block;float:none;width:870px;margin-left:0;} 159 | input.span16,textarea.span16{display:inline-block;float:none;width:930px;margin-left:0;} 160 | input[disabled],select[disabled],textarea[disabled],input[readonly],select[readonly],textarea[readonly]{background-color:#f5f5f5;border-color:#ddd;cursor:not-allowed;} 161 | .actions{background:#f5f5f5;margin-top:18px;margin-bottom:18px;padding:17px 20px 18px 150px;border-top:1px solid #ddd;-webkit-border-radius:0 0 3px 3px;-moz-border-radius:0 0 3px 3px;border-radius:0 0 3px 3px;}.actions .secondary-action{float:right;}.actions .secondary-action a{line-height:30px;}.actions .secondary-action a:hover{text-decoration:underline;} 162 | .help-inline,.help-block{font-size:13px;line-height:18px;color:#bfbfbf;} 163 | .help-inline{padding-left:5px;*position:relative;*top:-5px;} 164 | .help-block{display:block;max-width:600px;} 165 | .inline-inputs{color:#808080;}.inline-inputs span{padding:0 2px 0 1px;} 166 | .input-prepend input,.input-append input{-webkit-border-radius:0 3px 3px 0;-moz-border-radius:0 3px 3px 0;border-radius:0 3px 3px 0;} 167 | .input-prepend .add-on,.input-append .add-on{position:relative;background:#f5f5f5;border:1px solid #ccc;z-index:2;float:left;display:block;width:auto;min-width:16px;height:18px;padding:4px 4px 4px 5px;margin-right:-1px;font-weight:normal;line-height:18px;color:#bfbfbf;text-align:center;text-shadow:0 1px 0 #ffffff;-webkit-border-radius:3px 0 0 3px;-moz-border-radius:3px 0 0 3px;border-radius:3px 0 0 3px;} 168 | .input-prepend .active,.input-append .active{background:#a9dba9;border-color:#46a546;} 169 | .input-prepend .add-on{*margin-top:1px;} 170 | .input-append input{float:left;-webkit-border-radius:3px 0 0 3px;-moz-border-radius:3px 0 0 3px;border-radius:3px 0 0 3px;} 171 | .input-append .add-on{-webkit-border-radius:0 3px 3px 0;-moz-border-radius:0 3px 3px 0;border-radius:0 3px 3px 0;margin-right:0;margin-left:-1px;} 172 | .inputs-list{margin:0 0 5px;width:100%;}.inputs-list li{display:block;padding:0;width:100%;} 173 | .inputs-list label{display:block;float:none;width:auto;padding:0;margin-left:20px;line-height:18px;text-align:left;white-space:normal;}.inputs-list label strong{color:#808080;} 174 | .inputs-list label small{font-size:11px;font-weight:normal;} 175 | .inputs-list .inputs-list{margin-left:25px;margin-bottom:10px;padding-top:0;} 176 | .inputs-list:first-child{padding-top:6px;} 177 | .inputs-list li+li{padding-top:2px;} 178 | .inputs-list input[type=radio],.inputs-list input[type=checkbox]{margin-bottom:0;margin-left:-20px;float:left;} 179 | .form-stacked{padding-left:20px;}.form-stacked fieldset{padding-top:9px;} 180 | .form-stacked legend{padding-left:0;} 181 | .form-stacked label{display:block;float:none;width:auto;font-weight:bold;text-align:left;line-height:20px;padding-top:0;} 182 | .form-stacked .clearfix{margin-bottom:9px;}.form-stacked .clearfix div.input{margin-left:0;} 183 | .form-stacked .inputs-list{margin-bottom:0;}.form-stacked .inputs-list li{padding-top:0;}.form-stacked .inputs-list li label{font-weight:normal;padding-top:0;} 184 | .form-stacked div.clearfix.error{padding-top:10px;padding-bottom:10px;padding-left:10px;margin-top:0;margin-left:-10px;} 185 | .form-stacked .actions{margin-left:-20px;padding-left:20px;} 186 | table{width:100%;margin-bottom:18px;padding:0;font-size:13px;border-collapse:collapse;}table th,table td{padding:10px 10px 9px;line-height:18px;text-align:left;} 187 | table th{padding-top:9px;font-weight:bold;vertical-align:middle;} 188 | table td{vertical-align:top;border-top:1px solid #ddd;} 189 | table tbody th{border-top:1px solid #ddd;vertical-align:top;} 190 | .condensed-table th,.condensed-table td{padding:5px 5px 4px;} 191 | .bordered-table{border:1px solid #ddd;border-collapse:separate;*border-collapse:collapse;-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;}.bordered-table th+th,.bordered-table td+td,.bordered-table th+td{border-left:1px solid #ddd;} 192 | .bordered-table thead tr:first-child th:first-child,.bordered-table tbody tr:first-child td:first-child{-webkit-border-radius:4px 0 0 0;-moz-border-radius:4px 0 0 0;border-radius:4px 0 0 0;} 193 | .bordered-table thead tr:first-child th:last-child,.bordered-table tbody tr:first-child td:last-child{-webkit-border-radius:0 4px 0 0;-moz-border-radius:0 4px 0 0;border-radius:0 4px 0 0;} 194 | .bordered-table tbody tr:last-child td:first-child{-webkit-border-radius:0 0 0 4px;-moz-border-radius:0 0 0 4px;border-radius:0 0 0 4px;} 195 | .bordered-table tbody tr:last-child td:last-child{-webkit-border-radius:0 0 4px 0;-moz-border-radius:0 0 4px 0;border-radius:0 0 4px 0;} 196 | table .span1{width:20px;} 197 | table .span2{width:60px;} 198 | table .span3{width:100px;} 199 | table .span4{width:140px;} 200 | table .span5{width:180px;} 201 | table .span6{width:220px;} 202 | table .span7{width:260px;} 203 | table .span8{width:300px;} 204 | table .span9{width:340px;} 205 | table .span10{width:380px;} 206 | table .span11{width:420px;} 207 | table .span12{width:460px;} 208 | table .span13{width:500px;} 209 | table .span14{width:540px;} 210 | table .span15{width:580px;} 211 | table .span16{width:620px;} 212 | .zebra-striped tbody tr:nth-child(odd) td,.zebra-striped tbody tr:nth-child(odd) th{background-color:#f9f9f9;} 213 | .zebra-striped tbody tr:hover td,.zebra-striped tbody tr:hover th{background-color:#f5f5f5;} 214 | table .header{cursor:pointer;}table .header:after{content:"";float:right;margin-top:7px;border-width:0 4px 4px;border-style:solid;border-color:#000 transparent;visibility:hidden;} 215 | table .headerSortUp,table .headerSortDown{background-color:rgba(141, 192, 219, 0.25);text-shadow:0 1px 1px rgba(255, 255, 255, 0.75);} 216 | table .header:hover:after{visibility:visible;} 217 | table .headerSortDown:after,table .headerSortDown:hover:after{visibility:visible;filter:alpha(opacity=60);-khtml-opacity:0.6;-moz-opacity:0.6;opacity:0.6;} 218 | table .headerSortUp:after{border-bottom:none;border-left:4px solid transparent;border-right:4px solid transparent;border-top:4px solid #000;visibility:visible;-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;filter:alpha(opacity=60);-khtml-opacity:0.6;-moz-opacity:0.6;opacity:0.6;} 219 | table .blue{color:#049cdb;border-bottom-color:#049cdb;} 220 | table .headerSortUp.blue,table .headerSortDown.blue{background-color:#ade6fe;} 221 | table .green{color:#46a546;border-bottom-color:#46a546;} 222 | table .headerSortUp.green,table .headerSortDown.green{background-color:#cdeacd;} 223 | table .red{color:#9d261d;border-bottom-color:#9d261d;} 224 | table .headerSortUp.red,table .headerSortDown.red{background-color:#f4c8c5;} 225 | table .yellow{color:#ffc40d;border-bottom-color:#ffc40d;} 226 | table .headerSortUp.yellow,table .headerSortDown.yellow{background-color:#fff6d9;} 227 | table .orange{color:#f89406;border-bottom-color:#f89406;} 228 | table .headerSortUp.orange,table .headerSortDown.orange{background-color:#fee9cc;} 229 | table .purple{color:#7a43b6;border-bottom-color:#7a43b6;} 230 | table .headerSortUp.purple,table .headerSortDown.purple{background-color:#e2d5f0;} 231 | .topbar{height:40px;position:fixed;top:0;left:0;right:0;z-index:10000;overflow:visible;}.topbar a{color:#bfbfbf;text-shadow:0 -1px 0 rgba(0, 0, 0, 0.25);} 232 | .topbar h3 a:hover,.topbar .brand:hover,.topbar ul .active>a{background-color:#333;background-color:rgba(255, 255, 255, 0.05);color:#ffffff;text-decoration:none;} 233 | .topbar h3{position:relative;} 234 | .topbar h3 a,.topbar .brand{float:left;display:block;padding:8px 20px 12px;margin-left:-20px;color:#ffffff;font-size:20px;font-weight:200;line-height:1;} 235 | .topbar p{margin:0;line-height:40px;}.topbar p a:hover{background-color:transparent;color:#ffffff;} 236 | .topbar form{float:left;margin:5px 0 0 0;position:relative;filter:alpha(opacity=100);-khtml-opacity:1;-moz-opacity:1;opacity:1;} 237 | .topbar form.pull-right{float:right;} 238 | .topbar input{background-color:#444;background-color:rgba(255, 255, 255, 0.3);font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:normal;font-weight:13px;line-height:1;padding:4px 9px;color:#ffffff;color:rgba(255, 255, 255, 0.75);border:1px solid #111;-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;-webkit-box-shadow:inset 0 1px 2px rgba(0, 0, 0, 0.1),0 1px 0px rgba(255, 255, 255, 0.25);-moz-box-shadow:inset 0 1px 2px rgba(0, 0, 0, 0.1),0 1px 0px rgba(255, 255, 255, 0.25);box-shadow:inset 0 1px 2px rgba(0, 0, 0, 0.1),0 1px 0px rgba(255, 255, 255, 0.25);-webkit-transition:none;-moz-transition:none;-ms-transition:none;-o-transition:none;transition:none;}.topbar input:-moz-placeholder{color:#e6e6e6;} 239 | .topbar input::-webkit-input-placeholder{color:#e6e6e6;} 240 | .topbar input:hover{background-color:#bfbfbf;background-color:rgba(255, 255, 255, 0.5);color:#ffffff;} 241 | .topbar input:focus,.topbar input.focused{outline:0;background-color:#ffffff;color:#404040;text-shadow:0 1px 0 #ffffff;border:0;padding:5px 10px;-webkit-box-shadow:0 0 3px rgba(0, 0, 0, 0.15);-moz-box-shadow:0 0 3px rgba(0, 0, 0, 0.15);box-shadow:0 0 3px rgba(0, 0, 0, 0.15);} 242 | .topbar-inner,.topbar .fill{background-color:#222;background-color:#222222;background-repeat:repeat-x;background-image:-khtml-gradient(linear, left top, left bottom, from(#333333), to(#222222));background-image:-moz-linear-gradient(top, #333333, #222222);background-image:-ms-linear-gradient(top, #333333, #222222);background-image:-webkit-gradient(linear, left top, left bottom, color-stop(0%, #333333), color-stop(100%, #222222));background-image:-webkit-linear-gradient(top, #333333, #222222);background-image:-o-linear-gradient(top, #333333, #222222);background-image:linear-gradient(top, #333333, #222222);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#333333', endColorstr='#222222', GradientType=0);-webkit-box-shadow:0 1px 3px rgba(0, 0, 0, 0.25),inset 0 -1px 0 rgba(0, 0, 0, 0.1);-moz-box-shadow:0 1px 3px rgba(0, 0, 0, 0.25),inset 0 -1px 0 rgba(0, 0, 0, 0.1);box-shadow:0 1px 3px rgba(0, 0, 0, 0.25),inset 0 -1px 0 rgba(0, 0, 0, 0.1);} 243 | .topbar div>ul,.nav{display:block;float:left;margin:0 10px 0 0;position:relative;left:0;}.topbar div>ul>li,.nav>li{display:block;float:left;} 244 | .topbar div>ul a,.nav a{display:block;float:none;padding:10px 10px 11px;line-height:19px;text-decoration:none;}.topbar div>ul a:hover,.nav a:hover{color:#ffffff;text-decoration:none;} 245 | .topbar div>ul .active>a,.nav .active>a{background-color:#222;background-color:rgba(0, 0, 0, 0.5);} 246 | .topbar div>ul.secondary-nav,.nav.secondary-nav{float:right;margin-left:10px;margin-right:0;}.topbar div>ul.secondary-nav .menu-dropdown,.nav.secondary-nav .menu-dropdown,.topbar div>ul.secondary-nav .dropdown-menu,.nav.secondary-nav .dropdown-menu{right:0;border:0;} 247 | .topbar div>ul a.menu:hover,.nav a.menu:hover,.topbar div>ul li.open .menu,.nav li.open .menu,.topbar div>ul .dropdown-toggle:hover,.nav .dropdown-toggle:hover,.topbar div>ul .dropdown.open .dropdown-toggle,.nav .dropdown.open .dropdown-toggle{background:#444;background:rgba(255, 255, 255, 0.05);} 248 | .topbar div>ul .menu-dropdown,.nav .menu-dropdown,.topbar div>ul .dropdown-menu,.nav .dropdown-menu{background-color:#333;}.topbar div>ul .menu-dropdown a.menu,.nav .menu-dropdown a.menu,.topbar div>ul .dropdown-menu a.menu,.nav .dropdown-menu a.menu,.topbar div>ul .menu-dropdown .dropdown-toggle,.nav .menu-dropdown .dropdown-toggle,.topbar div>ul .dropdown-menu .dropdown-toggle,.nav .dropdown-menu .dropdown-toggle{color:#ffffff;}.topbar div>ul .menu-dropdown a.menu.open,.nav .menu-dropdown a.menu.open,.topbar div>ul .dropdown-menu a.menu.open,.nav .dropdown-menu a.menu.open,.topbar div>ul .menu-dropdown .dropdown-toggle.open,.nav .menu-dropdown .dropdown-toggle.open,.topbar div>ul .dropdown-menu .dropdown-toggle.open,.nav .dropdown-menu .dropdown-toggle.open{background:#444;background:rgba(255, 255, 255, 0.05);} 249 | .topbar div>ul .menu-dropdown li a,.nav .menu-dropdown li a,.topbar div>ul .dropdown-menu li a,.nav .dropdown-menu li a{color:#999;text-shadow:0 1px 0 rgba(0, 0, 0, 0.5);}.topbar div>ul .menu-dropdown li a:hover,.nav .menu-dropdown li a:hover,.topbar div>ul .dropdown-menu li a:hover,.nav .dropdown-menu li a:hover{background-color:#191919;background-repeat:repeat-x;background-image:-khtml-gradient(linear, left top, left bottom, from(#292929), to(#191919));background-image:-moz-linear-gradient(top, #292929, #191919);background-image:-ms-linear-gradient(top, #292929, #191919);background-image:-webkit-gradient(linear, left top, left bottom, color-stop(0%, #292929), color-stop(100%, #191919));background-image:-webkit-linear-gradient(top, #292929, #191919);background-image:-o-linear-gradient(top, #292929, #191919);background-image:linear-gradient(top, #292929, #191919);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#292929', endColorstr='#191919', GradientType=0);color:#ffffff;} 250 | .topbar div>ul .menu-dropdown .active a,.nav .menu-dropdown .active a,.topbar div>ul .dropdown-menu .active a,.nav .dropdown-menu .active a{color:#ffffff;} 251 | .topbar div>ul .menu-dropdown .divider,.nav .menu-dropdown .divider,.topbar div>ul .dropdown-menu .divider,.nav .dropdown-menu .divider{background-color:#222;border-color:#444;} 252 | .topbar ul .menu-dropdown li a,.topbar ul .dropdown-menu li a{padding:4px 15px;} 253 | li.menu,.dropdown{position:relative;} 254 | a.menu:after,.dropdown-toggle:after{width:0;height:0;display:inline-block;content:"↓";text-indent:-99999px;vertical-align:top;margin-top:8px;margin-left:4px;border-left:4px solid transparent;border-right:4px solid transparent;border-top:4px solid #ffffff;filter:alpha(opacity=50);-khtml-opacity:0.5;-moz-opacity:0.5;opacity:0.5;} 255 | .menu-dropdown,.dropdown-menu{background-color:#ffffff;float:left;display:none;position:absolute;top:40px;z-index:900;min-width:160px;max-width:220px;_width:160px;margin-left:0;margin-right:0;padding:6px 0;zoom:1;border-color:#999;border-color:rgba(0, 0, 0, 0.2);border-style:solid;border-width:0 1px 1px;-webkit-border-radius:0 0 6px 6px;-moz-border-radius:0 0 6px 6px;border-radius:0 0 6px 6px;-webkit-box-shadow:0 2px 4px rgba(0, 0, 0, 0.2);-moz-box-shadow:0 2px 4px rgba(0, 0, 0, 0.2);box-shadow:0 2px 4px rgba(0, 0, 0, 0.2);-webkit-background-clip:padding-box;-moz-background-clip:padding-box;background-clip:padding-box;}.menu-dropdown li,.dropdown-menu li{float:none;display:block;background-color:none;} 256 | .menu-dropdown .divider,.dropdown-menu .divider{height:1px;margin:5px 0;overflow:hidden;background-color:#eee;border-bottom:1px solid #ffffff;} 257 | .topbar .dropdown-menu a,.dropdown-menu a{display:block;padding:4px 15px;clear:both;font-weight:normal;line-height:18px;color:#808080;text-shadow:0 1px 0 #ffffff;}.topbar .dropdown-menu a:hover,.dropdown-menu a:hover,.topbar .dropdown-menu a.hover,.dropdown-menu a.hover{background-color:#dddddd;background-repeat:repeat-x;background-image:-khtml-gradient(linear, left top, left bottom, from(#eeeeee), to(#dddddd));background-image:-moz-linear-gradient(top, #eeeeee, #dddddd);background-image:-ms-linear-gradient(top, #eeeeee, #dddddd);background-image:-webkit-gradient(linear, left top, left bottom, color-stop(0%, #eeeeee), color-stop(100%, #dddddd));background-image:-webkit-linear-gradient(top, #eeeeee, #dddddd);background-image:-o-linear-gradient(top, #eeeeee, #dddddd);background-image:linear-gradient(top, #eeeeee, #dddddd);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#eeeeee', endColorstr='#dddddd', GradientType=0);color:#404040;text-decoration:none;-webkit-box-shadow:inset 0 1px 0 rgba(0, 0, 0, 0.025),inset 0 -1px rgba(0, 0, 0, 0.025);-moz-box-shadow:inset 0 1px 0 rgba(0, 0, 0, 0.025),inset 0 -1px rgba(0, 0, 0, 0.025);box-shadow:inset 0 1px 0 rgba(0, 0, 0, 0.025),inset 0 -1px rgba(0, 0, 0, 0.025);} 258 | .open .menu,.dropdown.open .menu,.open .dropdown-toggle,.dropdown.open .dropdown-toggle{color:#ffffff;background:#ccc;background:rgba(0, 0, 0, 0.3);} 259 | .open .menu-dropdown,.dropdown.open .menu-dropdown,.open .dropdown-menu,.dropdown.open .dropdown-menu{display:block;} 260 | .tabs,.pills{margin:0 0 18px;padding:0;list-style:none;zoom:1;}.tabs:before,.pills:before,.tabs:after,.pills:after{display:table;content:"";zoom:1;} 261 | .tabs:after,.pills:after{clear:both;} 262 | .tabs>li,.pills>li{float:left;}.tabs>li>a,.pills>li>a{display:block;} 263 | .tabs{border-color:#ddd;border-style:solid;border-width:0 0 1px;}.tabs>li{position:relative;margin-bottom:-1px;}.tabs>li>a{padding:0 15px;margin-right:2px;line-height:34px;border:1px solid transparent;-webkit-border-radius:4px 4px 0 0;-moz-border-radius:4px 4px 0 0;border-radius:4px 4px 0 0;}.tabs>li>a:hover{text-decoration:none;background-color:#eee;border-color:#eee #eee #ddd;} 264 | .tabs .active>a,.tabs .active>a:hover{color:#808080;background-color:#ffffff;border:1px solid #ddd;border-bottom-color:transparent;cursor:default;} 265 | .tabs .menu-dropdown,.tabs .dropdown-menu{top:35px;border-width:1px;-webkit-border-radius:0 6px 6px 6px;-moz-border-radius:0 6px 6px 6px;border-radius:0 6px 6px 6px;} 266 | .tabs a.menu:after,.tabs .dropdown-toggle:after{border-top-color:#999;margin-top:15px;margin-left:5px;} 267 | .tabs li.open.menu .menu,.tabs .open.dropdown .dropdown-toggle{border-color:#999;} 268 | .tabs li.open a.menu:after,.tabs .dropdown.open .dropdown-toggle:after{border-top-color:#555;} 269 | .pills a{margin:5px 3px 5px 0;padding:0 15px;line-height:30px;text-shadow:0 1px 1px #ffffff;-webkit-border-radius:15px;-moz-border-radius:15px;border-radius:15px;}.pills a:hover{color:#ffffff;text-decoration:none;text-shadow:0 1px 1px rgba(0, 0, 0, 0.25);background-color:#00438a;} 270 | .pills .active a{color:#ffffff;text-shadow:0 1px 1px rgba(0, 0, 0, 0.25);background-color:#0069d6;} 271 | .pills-vertical>li{float:none;} 272 | .tab-content>.tab-pane,.pill-content>.pill-pane,.tab-content>div,.pill-content>div{display:none;} 273 | .tab-content>.active,.pill-content>.active{display:block;} 274 | .breadcrumb{padding:7px 14px;margin:0 0 18px;background-color:#f5f5f5;background-repeat:repeat-x;background-image:-khtml-gradient(linear, left top, left bottom, from(#ffffff), to(#f5f5f5));background-image:-moz-linear-gradient(top, #ffffff, #f5f5f5);background-image:-ms-linear-gradient(top, #ffffff, #f5f5f5);background-image:-webkit-gradient(linear, left top, left bottom, color-stop(0%, #ffffff), color-stop(100%, #f5f5f5));background-image:-webkit-linear-gradient(top, #ffffff, #f5f5f5);background-image:-o-linear-gradient(top, #ffffff, #f5f5f5);background-image:linear-gradient(top, #ffffff, #f5f5f5);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffff', endColorstr='#f5f5f5', GradientType=0);border:1px solid #ddd;-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px;-webkit-box-shadow:inset 0 1px 0 #ffffff;-moz-box-shadow:inset 0 1px 0 #ffffff;box-shadow:inset 0 1px 0 #ffffff;}.breadcrumb li{display:inline;text-shadow:0 1px 0 #ffffff;} 275 | .breadcrumb .divider{padding:0 5px;color:#bfbfbf;} 276 | .breadcrumb .active a{color:#404040;} 277 | .hero-unit{background-color:#f5f5f5;margin-bottom:30px;padding:60px;-webkit-border-radius:6px;-moz-border-radius:6px;border-radius:6px;}.hero-unit h1{margin-bottom:0;font-size:60px;line-height:1;letter-spacing:-1px;} 278 | .hero-unit p{font-size:18px;font-weight:200;line-height:27px;} 279 | footer{margin-top:17px;padding-top:17px;border-top:1px solid #eee;} 280 | .page-header{margin-bottom:17px;border-bottom:1px solid #ddd;-webkit-box-shadow:0 1px 0 rgba(255, 255, 255, 0.5);-moz-box-shadow:0 1px 0 rgba(255, 255, 255, 0.5);box-shadow:0 1px 0 rgba(255, 255, 255, 0.5);}.page-header h1{margin-bottom:8px;} 281 | .btn.danger,.alert-message.danger,.btn.danger:hover,.alert-message.danger:hover,.btn.error,.alert-message.error,.btn.error:hover,.alert-message.error:hover,.btn.success,.alert-message.success,.btn.success:hover,.alert-message.success:hover,.btn.info,.alert-message.info,.btn.info:hover,.alert-message.info:hover{color:#ffffff;} 282 | .btn .close,.alert-message .close{font-family:Arial,sans-serif;line-height:18px;} 283 | .btn.danger,.alert-message.danger,.btn.error,.alert-message.error{background-color:#c43c35;background-repeat:repeat-x;background-image:-khtml-gradient(linear, left top, left bottom, from(#ee5f5b), to(#c43c35));background-image:-moz-linear-gradient(top, #ee5f5b, #c43c35);background-image:-ms-linear-gradient(top, #ee5f5b, #c43c35);background-image:-webkit-gradient(linear, left top, left bottom, color-stop(0%, #ee5f5b), color-stop(100%, #c43c35));background-image:-webkit-linear-gradient(top, #ee5f5b, #c43c35);background-image:-o-linear-gradient(top, #ee5f5b, #c43c35);background-image:linear-gradient(top, #ee5f5b, #c43c35);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ee5f5b', endColorstr='#c43c35', GradientType=0);text-shadow:0 -1px 0 rgba(0, 0, 0, 0.25);border-color:#c43c35 #c43c35 #882a25;border-color:rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25);} 284 | .btn.success,.alert-message.success{background-color:#57a957;background-repeat:repeat-x;background-image:-khtml-gradient(linear, left top, left bottom, from(#62c462), to(#57a957));background-image:-moz-linear-gradient(top, #62c462, #57a957);background-image:-ms-linear-gradient(top, #62c462, #57a957);background-image:-webkit-gradient(linear, left top, left bottom, color-stop(0%, #62c462), color-stop(100%, #57a957));background-image:-webkit-linear-gradient(top, #62c462, #57a957);background-image:-o-linear-gradient(top, #62c462, #57a957);background-image:linear-gradient(top, #62c462, #57a957);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#62c462', endColorstr='#57a957', GradientType=0);text-shadow:0 -1px 0 rgba(0, 0, 0, 0.25);border-color:#57a957 #57a957 #3d773d;border-color:rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25);} 285 | .btn.info,.alert-message.info{background-color:#339bb9;background-repeat:repeat-x;background-image:-khtml-gradient(linear, left top, left bottom, from(#5bc0de), to(#339bb9));background-image:-moz-linear-gradient(top, #5bc0de, #339bb9);background-image:-ms-linear-gradient(top, #5bc0de, #339bb9);background-image:-webkit-gradient(linear, left top, left bottom, color-stop(0%, #5bc0de), color-stop(100%, #339bb9));background-image:-webkit-linear-gradient(top, #5bc0de, #339bb9);background-image:-o-linear-gradient(top, #5bc0de, #339bb9);background-image:linear-gradient(top, #5bc0de, #339bb9);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#5bc0de', endColorstr='#339bb9', GradientType=0);text-shadow:0 -1px 0 rgba(0, 0, 0, 0.25);border-color:#339bb9 #339bb9 #22697d;border-color:rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25);} 286 | .btn{cursor:pointer;display:inline-block;background-color:#e6e6e6;background-repeat:no-repeat;background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#ffffff), color-stop(25%, #ffffff), to(#e6e6e6));background-image:-webkit-linear-gradient(#ffffff, #ffffff 25%, #e6e6e6);background-image:-moz-linear-gradient(top, #ffffff, #ffffff 25%, #e6e6e6);background-image:-ms-linear-gradient(#ffffff, #ffffff 25%, #e6e6e6);background-image:-o-linear-gradient(#ffffff, #ffffff 25%, #e6e6e6);background-image:linear-gradient(#ffffff, #ffffff 25%, #e6e6e6);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffff', endColorstr='#e6e6e6', GradientType=0);padding:5px 14px 6px;text-shadow:0 1px 1px rgba(255, 255, 255, 0.75);color:#333;font-size:13px;line-height:normal;border:1px solid #ccc;border-bottom-color:#bbb;-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;-webkit-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.2),0 1px 2px rgba(0, 0, 0, 0.05);-moz-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.2),0 1px 2px rgba(0, 0, 0, 0.05);box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.2),0 1px 2px rgba(0, 0, 0, 0.05);-webkit-transition:0.1s linear all;-moz-transition:0.1s linear all;-ms-transition:0.1s linear all;-o-transition:0.1s linear all;transition:0.1s linear all;}.btn:hover{background-position:0 -15px;color:#333;text-decoration:none;} 287 | .btn:focus{outline:1px dotted #666;} 288 | .btn.primary{color:#ffffff;background-color:#0064cd;background-repeat:repeat-x;background-image:-khtml-gradient(linear, left top, left bottom, from(#049cdb), to(#0064cd));background-image:-moz-linear-gradient(top, #049cdb, #0064cd);background-image:-ms-linear-gradient(top, #049cdb, #0064cd);background-image:-webkit-gradient(linear, left top, left bottom, color-stop(0%, #049cdb), color-stop(100%, #0064cd));background-image:-webkit-linear-gradient(top, #049cdb, #0064cd);background-image:-o-linear-gradient(top, #049cdb, #0064cd);background-image:linear-gradient(top, #049cdb, #0064cd);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#049cdb', endColorstr='#0064cd', GradientType=0);text-shadow:0 -1px 0 rgba(0, 0, 0, 0.25);border-color:#0064cd #0064cd #003f81;border-color:rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25);} 289 | .btn.active,.btn:active{-webkit-box-shadow:inset 0 2px 4px rgba(0, 0, 0, 0.25),0 1px 2px rgba(0, 0, 0, 0.05);-moz-box-shadow:inset 0 2px 4px rgba(0, 0, 0, 0.25),0 1px 2px rgba(0, 0, 0, 0.05);box-shadow:inset 0 2px 4px rgba(0, 0, 0, 0.25),0 1px 2px rgba(0, 0, 0, 0.05);} 290 | .btn.disabled{cursor:default;background-image:none;filter:progid:DXImageTransform.Microsoft.gradient(enabled = false);filter:alpha(opacity=65);-khtml-opacity:0.65;-moz-opacity:0.65;opacity:0.65;-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;} 291 | .btn[disabled]{cursor:default;background-image:none;filter:progid:DXImageTransform.Microsoft.gradient(enabled = false);filter:alpha(opacity=65);-khtml-opacity:0.65;-moz-opacity:0.65;opacity:0.65;-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;} 292 | .btn.large{font-size:15px;line-height:normal;padding:9px 14px 9px;-webkit-border-radius:6px;-moz-border-radius:6px;border-radius:6px;} 293 | .btn.small{padding:7px 9px 7px;font-size:11px;} 294 | :root .alert-message,:root .btn{border-radius:0 \0;} 295 | button.btn::-moz-focus-inner,input[type=submit].btn::-moz-focus-inner{padding:0;border:0;} 296 | .close{float:right;color:#000000;font-size:20px;font-weight:bold;line-height:13.5px;text-shadow:0 1px 0 #ffffff;filter:alpha(opacity=25);-khtml-opacity:0.25;-moz-opacity:0.25;opacity:0.25;}.close:hover{color:#000000;text-decoration:none;filter:alpha(opacity=40);-khtml-opacity:0.4;-moz-opacity:0.4;opacity:0.4;} 297 | .alert-message{position:relative;padding:7px 15px;margin-bottom:18px;color:#404040;background-color:#eedc94;background-repeat:repeat-x;background-image:-khtml-gradient(linear, left top, left bottom, from(#fceec1), to(#eedc94));background-image:-moz-linear-gradient(top, #fceec1, #eedc94);background-image:-ms-linear-gradient(top, #fceec1, #eedc94);background-image:-webkit-gradient(linear, left top, left bottom, color-stop(0%, #fceec1), color-stop(100%, #eedc94));background-image:-webkit-linear-gradient(top, #fceec1, #eedc94);background-image:-o-linear-gradient(top, #fceec1, #eedc94);background-image:linear-gradient(top, #fceec1, #eedc94);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fceec1', endColorstr='#eedc94', GradientType=0);text-shadow:0 -1px 0 rgba(0, 0, 0, 0.25);border-color:#eedc94 #eedc94 #e4c652;border-color:rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25);text-shadow:0 1px 0 rgba(255, 255, 255, 0.5);border-width:1px;border-style:solid;-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;-webkit-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.25);-moz-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.25);box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.25);}.alert-message .close{margin-top:1px;*margin-top:0;} 298 | .alert-message a{font-weight:bold;color:#404040;} 299 | .alert-message.danger p a,.alert-message.error p a,.alert-message.success p a,.alert-message.info p a{color:#ffffff;} 300 | .alert-message h5{line-height:18px;} 301 | .alert-message p{margin-bottom:0;} 302 | .alert-message div{margin-top:5px;margin-bottom:2px;line-height:28px;} 303 | .alert-message .btn{-webkit-box-shadow:0 1px 0 rgba(255, 255, 255, 0.25);-moz-box-shadow:0 1px 0 rgba(255, 255, 255, 0.25);box-shadow:0 1px 0 rgba(255, 255, 255, 0.25);} 304 | .alert-message.block-message{background-image:none;background-color:#fdf5d9;filter:progid:DXImageTransform.Microsoft.gradient(enabled = false);padding:14px;border-color:#fceec1;-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;}.alert-message.block-message ul,.alert-message.block-message p{margin-right:30px;} 305 | .alert-message.block-message ul{margin-bottom:0;} 306 | .alert-message.block-message li{color:#404040;} 307 | .alert-message.block-message .alert-actions{margin-top:5px;} 308 | .alert-message.block-message.error,.alert-message.block-message.success,.alert-message.block-message.info{color:#404040;text-shadow:0 1px 0 rgba(255, 255, 255, 0.5);} 309 | .alert-message.block-message.error{background-color:#fddfde;border-color:#fbc7c6;} 310 | .alert-message.block-message.success{background-color:#d1eed1;border-color:#bfe7bf;} 311 | .alert-message.block-message.info{background-color:#ddf4fb;border-color:#c6edf9;} 312 | .alert-message.block-message.danger p a,.alert-message.block-message.error p a,.alert-message.block-message.success p a,.alert-message.block-message.info p a{color:#404040;} 313 | .pagination{height:36px;margin:18px 0;}.pagination ul{float:left;margin:0;border:1px solid #ddd;border:1px solid rgba(0, 0, 0, 0.15);-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px;-webkit-box-shadow:0 1px 2px rgba(0, 0, 0, 0.05);-moz-box-shadow:0 1px 2px rgba(0, 0, 0, 0.05);box-shadow:0 1px 2px rgba(0, 0, 0, 0.05);} 314 | .pagination li{display:inline;} 315 | .pagination a{float:left;padding:0 14px;line-height:34px;border-right:1px solid;border-right-color:#ddd;border-right-color:rgba(0, 0, 0, 0.15);*border-right-color:#ddd;text-decoration:none;} 316 | .pagination a:hover,.pagination .active a{background-color:#c7eefe;} 317 | .pagination .disabled a,.pagination .disabled a:hover{background-color:transparent;color:#bfbfbf;} 318 | .pagination .next a{border:0;} 319 | .well{background-color:#f5f5f5;margin-bottom:20px;padding:19px;min-height:20px;border:1px solid #eee;border:1px solid rgba(0, 0, 0, 0.05);-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;-webkit-box-shadow:inset 0 1px 1px rgba(0, 0, 0, 0.05);-moz-box-shadow:inset 0 1px 1px rgba(0, 0, 0, 0.05);box-shadow:inset 0 1px 1px rgba(0, 0, 0, 0.05);}.well blockquote{border-color:#ddd;border-color:rgba(0, 0, 0, 0.15);} 320 | .modal-backdrop{background-color:#000000;position:fixed;top:0;left:0;right:0;bottom:0;z-index:10000;}.modal-backdrop.fade{opacity:0;} 321 | .modal-backdrop,.modal-backdrop.fade.in{filter:alpha(opacity=80);-khtml-opacity:0.8;-moz-opacity:0.8;opacity:0.8;} 322 | .modal{position:fixed;top:50%;left:50%;z-index:11000;width:560px;margin:-250px 0 0 -280px;background-color:#ffffff;border:1px solid #999;border:1px solid rgba(0, 0, 0, 0.3);*border:1px solid #999;-webkit-border-radius:6px;-moz-border-radius:6px;border-radius:6px;-webkit-box-shadow:0 3px 7px rgba(0, 0, 0, 0.3);-moz-box-shadow:0 3px 7px rgba(0, 0, 0, 0.3);box-shadow:0 3px 7px rgba(0, 0, 0, 0.3);-webkit-background-clip:padding-box;-moz-background-clip:padding-box;background-clip:padding-box;}.modal .close{margin-top:7px;} 323 | .modal.fade{-webkit-transition:opacity .3s linear, top .3s ease-out;-moz-transition:opacity .3s linear, top .3s ease-out;-ms-transition:opacity .3s linear, top .3s ease-out;-o-transition:opacity .3s linear, top .3s ease-out;transition:opacity .3s linear, top .3s ease-out;top:-25%;} 324 | .modal.fade.in{top:50%;} 325 | .modal-header{border-bottom:1px solid #eee;padding:5px 15px;} 326 | .modal-body{padding:15px;} 327 | .modal-body form{margin-bottom:0;} 328 | .modal-footer{background-color:#f5f5f5;padding:14px 15px 15px;border-top:1px solid #ddd;-webkit-border-radius:0 0 6px 6px;-moz-border-radius:0 0 6px 6px;border-radius:0 0 6px 6px;-webkit-box-shadow:inset 0 1px 0 #ffffff;-moz-box-shadow:inset 0 1px 0 #ffffff;box-shadow:inset 0 1px 0 #ffffff;zoom:1;margin-bottom:0;}.modal-footer:before,.modal-footer:after{display:table;content:"";zoom:1;} 329 | .modal-footer:after{clear:both;} 330 | .modal-footer .btn{float:right;margin-left:5px;} 331 | .modal .popover,.modal .twipsy{z-index:12000;} 332 | .twipsy{display:block;position:absolute;visibility:visible;padding:5px;font-size:11px;z-index:1000;filter:alpha(opacity=80);-khtml-opacity:0.8;-moz-opacity:0.8;opacity:0.8;}.twipsy.fade.in{filter:alpha(opacity=80);-khtml-opacity:0.8;-moz-opacity:0.8;opacity:0.8;} 333 | .twipsy.above .twipsy-arrow{bottom:0;left:50%;margin-left:-5px;border-left:5px solid transparent;border-right:5px solid transparent;border-top:5px solid #000000;} 334 | .twipsy.left .twipsy-arrow{top:50%;right:0;margin-top:-5px;border-top:5px solid transparent;border-bottom:5px solid transparent;border-left:5px solid #000000;} 335 | .twipsy.below .twipsy-arrow{top:0;left:50%;margin-left:-5px;border-left:5px solid transparent;border-right:5px solid transparent;border-bottom:5px solid #000000;} 336 | .twipsy.right .twipsy-arrow{top:50%;left:0;margin-top:-5px;border-top:5px solid transparent;border-bottom:5px solid transparent;border-right:5px solid #000000;} 337 | .twipsy-inner{padding:3px 8px;background-color:#000000;color:white;text-align:center;max-width:200px;text-decoration:none;-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;} 338 | .twipsy-arrow{position:absolute;width:0;height:0;} 339 | .popover{position:absolute;top:0;left:0;z-index:1000;padding:5px;display:none;}.popover.above .arrow{bottom:0;left:50%;margin-left:-5px;border-left:5px solid transparent;border-right:5px solid transparent;border-top:5px solid #000000;} 340 | .popover.right .arrow{top:50%;left:0;margin-top:-5px;border-top:5px solid transparent;border-bottom:5px solid transparent;border-right:5px solid #000000;} 341 | .popover.below .arrow{top:0;left:50%;margin-left:-5px;border-left:5px solid transparent;border-right:5px solid transparent;border-bottom:5px solid #000000;} 342 | .popover.left .arrow{top:50%;right:0;margin-top:-5px;border-top:5px solid transparent;border-bottom:5px solid transparent;border-left:5px solid #000000;} 343 | .popover .arrow{position:absolute;width:0;height:0;} 344 | .popover .inner{background:#000000;background:rgba(0, 0, 0, 0.8);padding:3px;overflow:hidden;width:280px;-webkit-border-radius:6px;-moz-border-radius:6px;border-radius:6px;-webkit-box-shadow:0 3px 7px rgba(0, 0, 0, 0.3);-moz-box-shadow:0 3px 7px rgba(0, 0, 0, 0.3);box-shadow:0 3px 7px rgba(0, 0, 0, 0.3);} 345 | .popover .title{background-color:#f5f5f5;padding:9px 15px;line-height:1;-webkit-border-radius:3px 3px 0 0;-moz-border-radius:3px 3px 0 0;border-radius:3px 3px 0 0;border-bottom:1px solid #eee;} 346 | .popover .content{background-color:#ffffff;padding:14px;-webkit-border-radius:0 0 3px 3px;-moz-border-radius:0 0 3px 3px;border-radius:0 0 3px 3px;-webkit-background-clip:padding-box;-moz-background-clip:padding-box;background-clip:padding-box;}.popover .content p,.popover .content ul,.popover .content ol{margin-bottom:0;} 347 | .fade{-webkit-transition:opacity 0.15s linear;-moz-transition:opacity 0.15s linear;-ms-transition:opacity 0.15s linear;-o-transition:opacity 0.15s linear;transition:opacity 0.15s linear;opacity:0;}.fade.in{opacity:1;} 348 | .label{padding:1px 3px 2px;font-size:9.75px;font-weight:bold;color:#ffffff;text-transform:uppercase;white-space:nowrap;background-color:#bfbfbf;-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px;}.label.important{background-color:#c43c35;} 349 | .label.warning{background-color:#f89406;} 350 | .label.success{background-color:#46a546;} 351 | .label.notice{background-color:#62cffc;} 352 | .media-grid{margin-left:-20px;margin-bottom:0;zoom:1;}.media-grid:before,.media-grid:after{display:table;content:"";zoom:1;} 353 | .media-grid:after{clear:both;} 354 | .media-grid li{display:inline;} 355 | .media-grid a{float:left;padding:4px;margin:0 0 18px 20px;border:1px solid #ddd;-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;-webkit-box-shadow:0 1px 1px rgba(0, 0, 0, 0.075);-moz-box-shadow:0 1px 1px rgba(0, 0, 0, 0.075);box-shadow:0 1px 1px rgba(0, 0, 0, 0.075);}.media-grid a img{display:block;} 356 | .media-grid a:hover{border-color:#0069d6;-webkit-box-shadow:0 1px 4px rgba(0, 105, 214, 0.25);-moz-box-shadow:0 1px 4px rgba(0, 105, 214, 0.25);box-shadow:0 1px 4px rgba(0, 105, 214, 0.25);} 357 | --------------------------------------------------------------------------------