first commit

This commit is contained in:
Sami Samhuri 2012-02-05 22:01:28 -08:00
commit 86f0ef71bf
142 changed files with 15987 additions and 0 deletions

8
.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
*.tmproj
log/
pid
public/photos/*
public/videos/*
public/js-min
public/css-min
coverage

19
Gemfile Normal file
View file

@ -0,0 +1,19 @@
source :rubygems
gem 'rack'
gem 'rack-test'
gem 'sinatra-flash'
gem 'sinatra'
gem 'redis', '>= 2.2.0', :require => ['redis', 'redis/connection/hiredis']
gem 'thin'
gem 'rmagick'
gem 'bcrypt-ruby'
gem 'pony'
gem 'erubis'
gem 'minitest'
gem 'activesupport'
gem 'uuid'
gem 'sinatra-cookie_thief'
gem 'rdiscount'
gem 'redis-store', :git => 'https://github.com/betastreet/redis-store.git', :branch => '1.0.x'
gem 'simplecov', :require => false

83
Gemfile.lock Normal file
View file

@ -0,0 +1,83 @@
GIT
remote: https://github.com/betastreet/redis-store.git
revision: 1d4b475fb975a8ad361744346edc7172acf8869f
branch: 1.0.x
specs:
redis-store (1.0.0.1)
redis (~> 2.2.1)
GEM
remote: http://rubygems.org/
specs:
activesupport (3.1.3)
multi_json (~> 1.0)
bcrypt-ruby (3.0.1)
daemons (1.1.6)
erubis (2.7.0)
eventmachine (0.12.10)
i18n (0.6.0)
macaddr (1.5.0)
systemu (>= 2.4.0)
mail (2.4.1)
i18n (>= 0.4.0)
mime-types (~> 1.16)
treetop (~> 1.4.8)
mime-types (1.17.2)
minitest (2.11.1)
multi_json (1.0.4)
polyglot (0.3.3)
pony (1.4)
mail (> 2.0)
rack (1.4.1)
rack-protection (1.2.0)
rack
rack-test (0.6.1)
rack (>= 1.0)
rdiscount (1.6.8)
redis (2.2.2)
rmagick (2.13.1)
simplecov (0.5.4)
multi_json (~> 1.0.3)
simplecov-html (~> 0.5.3)
simplecov-html (0.5.3)
sinatra (1.3.2)
rack (~> 1.3, >= 1.3.6)
rack-protection (~> 1.2)
tilt (~> 1.3, >= 1.3.3)
sinatra-cookie_thief (0.1.1)
sinatra (>= 1.0)
sinatra-flash (0.3.0)
sinatra (>= 1.0.0)
systemu (2.4.2)
thin (1.3.1)
daemons (>= 1.0.9)
eventmachine (>= 0.12.6)
rack (>= 1.0.0)
tilt (1.3.3)
treetop (1.4.10)
polyglot
polyglot (>= 0.3.1)
uuid (2.3.5)
macaddr (~> 1.0)
PLATFORMS
ruby
DEPENDENCIES
activesupport
bcrypt-ruby
erubis
minitest
pony
rack
rack-test
rdiscount
redis (>= 2.2.0)
redis-store!
rmagick
simplecov
sinatra
sinatra-cookie_thief
sinatra-flash
thin
uuid

49
Rakefile Normal file
View file

@ -0,0 +1,49 @@
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
require 'rake/testtask'
Rake::TestTask.new do |t|
t.libs << 'test'
t.test_files = FileList['test/**/test-*.rb']
end
task 'nuke-test-data' do
require 'rubygems'
require 'bundler/setup'
require 'redis'
redis = Redis.new
redis.keys('TEST:stormy:*').each do |key|
redis.del key
end
end
MinifiedCSSDir = 'public/css-min'
MinifiedJSDir = 'public/js-min'
desc "Minifies JS and CSS"
task :minify do
puts "Minifying JavaScript..."
Dir.mkdir(MinifiedJSDir) unless File.exists?(MinifiedJSDir)
Dir['public/js/*.js'].each do |path|
filename = File.basename(path)
out = File.join(MinifiedJSDir, filename)
if !File.exists?(out) || File.mtime(path) > File.mtime(out)
puts " * #{filename}"
`bin/closure <"#{path}" >"#{out}"`
end
end
puts "Minifying CSS..."
Dir.mkdir(MinifiedCSSDir) unless File.exists?(MinifiedCSSDir)
Dir['public/css/*.css'].each do |path|
filename = File.basename(path)
out = File.join(MinifiedCSSDir, filename)
if !File.exists?(out) || File.mtime(path) > File.mtime(out)
puts " * #{filename}"
`bin/yui-compressor "#{path}" "#{out}"`
end
end
puts "Done."
end

68
Readme.md Normal file
View file

@ -0,0 +1,68 @@
# Stormy Weather
A web server blueprint using Sinatra and Redis.
Storm Weather is only a vague idea. Trying to nail down a lightweight blueprint for
projects that have user accounts and admin accounts.
## Models
### Account Model
### Admin Model
## Controllers
### Account Controller
### Admin Controller
### Public Controller
## Helpers
### Accounts
### Admin
### Authorization
### FAQ
### Utils
### Views
## Tests
% rake test
The tests are pretty slow. Mocking out Redis didn't improve the speed of tests. Advice on speeding them up is greatly appreciated.
## Thanks
These truly are gems. Thanks to all the maintainers.
- ActiveSupport
- Bcrypt
- Erubis
- Pony
- Rack and Rack::Test
- RDiscount
- Redis
- Rmagick
- SimpleCov
- Sinatra
- Thin
## License
[Licensed under the terms of the MIT license](http://sjs.mit-license.org).
&copy; 2012 Sami Samhuri &lt;[sami@samhuri.net](mailto:sami@samhuri.net)&gt;

3
bin/closure Executable file
View file

@ -0,0 +1,3 @@
#!/bin/bash
java -jar $(dirname $0)/compiler.jar "$@"

BIN
bin/compiler.jar Normal file

Binary file not shown.

16
bin/console Executable file
View file

@ -0,0 +1,16 @@
#!/usr/bin/env ruby
#
# Bash version:
# exec irb --readline --simple-prompt -r irb/completion -I $(dirname "$0")/../lib -r stormy
$LOAD_PATH << File.expand_path('../lib', File.dirname(__FILE__))
require 'stormy'
require 'stormy/models'
include Stormy::Models
require 'irb'
require 'irb/completion'
ARGV << '--simple-prompt'
IRB.start

7
bin/mkpass.rb Executable file
View file

@ -0,0 +1,7 @@
#!/usr/bin/env ruby
require 'rubygems'
require 'bundler/setup'
require 'bcrypt'
puts BCrypt::Password.create(ARGV.first)

5
bin/restart.sh Executable file
View file

@ -0,0 +1,5 @@
#!/bin/zsh
[[ -d /web/stormy ]] && cd /web/stormy
bin/stop.sh
bin/start.sh

9
bin/start.rb Executable file
View file

@ -0,0 +1,9 @@
#!/usr/bin/env ruby
#
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
$LOAD_PATH.unshift File.join(File.dirname(__FILE__), '..', 'lib')
require 'stormy'
require 'stormy/server'
Stormy::Server.run!

17
bin/start.sh Executable file
View file

@ -0,0 +1,17 @@
#!/bin/zsh
[[ -d "$HOME/.rbenv" ]] && export PATH="$HOME/.rbenv/shims:$PATH"
if [[ "$RACK_ENV" = "development" ]]; then
exec shotgun -s thin -o 0.0.0.0 -p 5000 config.ru
else
[[ -d /web/stormy ]] && cd /web/stormy
[[ -d log ]] || mkdir log
RACK_ENV=production bin/start.rb >>|log/access.log 2>>|log/access.log &!
if [[ $? -eq 0 ]]; then
echo $! >|./pid
else
echo "!! Failed to start. Last bit of the log:"
tail -n 20 log/access.log
fi
fi

22
bin/stop.sh Executable file
View file

@ -0,0 +1,22 @@
#!/bin/zsh
[[ -d /web/stormy ]] && cd /web/stormy
if [[ -r pid ]]; then
PID=$(cat pid)
RETRIES=3
while [[ $RETRIES -gt 0 ]] && ps ax | grep "${PID}[ ]" >/dev/null; do
kill $PID
sleep 1
RETRIES=$((RETRIES - 1))
done
if ps ax | grep "${PID}[ ]" >/dev/null; then
kill -9 $PID
sleep 1
fi
rm pid
fi

3
bin/yui-compressor Executable file
View file

@ -0,0 +1,3 @@
#!/bin/bash
java -jar $(dirname $0)/yuicompressor-2.4.7.jar "$1" -o "$2"

BIN
bin/yuicompressor-2.4.7.jar Normal file

Binary file not shown.

9
config.ru Executable file
View file

@ -0,0 +1,9 @@
#!/usr/bin/env ruby
#
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
$LOAD_PATH.unshift('lib')
require 'stormy'
require 'stormy/server'
run Stormy::Server

12
lib/hash-ext.rb Normal file
View file

@ -0,0 +1,12 @@
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
class Hash
def slice(*keys)
keys.inject({}) do |h, k|
h[k] = self[k] if has_key?(k)
h
end
end
end

37
lib/stormy.rb Normal file
View file

@ -0,0 +1,37 @@
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
require 'date'
require 'rubygems'
require 'bundler/setup'
require 'active_support/core_ext'
this_dir = File.dirname(__FILE__)
$LOAD_PATH.unshift(this_dir) unless $LOAD_PATH.include?(this_dir)
module Stormy
# key prefix for data stored in Redis (used for testing)
unless const_defined? :KeyPrefix
KeyPrefix = ''
end
# public directory for project photos
unless const_defined? :PhotoDir
PhotoDir = File.expand_path('../public/photos', File.dirname(__FILE__))
end
# public directory for project videos
unless const_defined? :VideoDir
VideoDir = File.expand_path('../public/videos', File.dirname(__FILE__))
end
def self.key_prefix
@key_prefix ||= "#{KeyPrefix}stormy:"
end
def self.key(*components)
key_prefix + components.compact.join(':')
end
end

75
lib/stormy/config.rb Normal file
View file

@ -0,0 +1,75 @@
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
require 'redis'
module Stormy
class Config
DefaultConfig = {
}
ConfigTypes = {
}
# shared instance
def self.instance
@@instance ||= new
end
def initialize
reload!
if config.size == 0 && DefaultConfig.size > 0
redis.hmset(config_key, *DefaultConfig.to_a.flatten)
reload!
end
end
def config_key
@config_key ||= Stormy.key('config')
end
def config
@config ||= redis.hgetall(config_key)
end
def redis
@redis ||= Redis.new
end
def reload!
@config = nil
config
if config.size > 0
ConfigTypes.each do |name, type|
if type == :integer
config[name] = config[name].to_i
elsif type == :boolean
config[name] = config[name] == 'true'
end
end
end
end
def method_missing(name, *args)
name = name.to_s
# TODO: decide if we should call super for unknown names
if name.ends_with?('=')
name = name.sub(/=$/, '')
value = args.first
redis.hset(config_key, name, value)
config[name] = value
elsif config.has_key?(name)
config[name]
elsif DefaultConfig.has_key?(name)
value = DefaultConfig[name]
redis.hset(config_key, name, value)
config[name] = value
else
super(name.to_sym, *args)
end
end
end
end

View file

@ -0,0 +1,5 @@
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
Dir[File.dirname(__FILE__) + '/controllers/*.rb'].each do |f|
require 'stormy/controllers/' + File.basename(f)
end

View file

@ -0,0 +1,221 @@
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
module Stormy
class Server < Sinatra::Base
get '/sign-up' do
redirect '/projects' if authorized? && production?
title 'Sign Up'
stylesheet 'sign-up'
script 'sign-up'
@errors = session.delete(:errors) if session[:errors]
@fields = session.delete(:fields) || {}
erb :'sign-up'
end
post '/sign-up' do
session.delete('source') if session['source']
fields = params.slice(*Account.fields.map { |name, options| name.to_s if options[:updatable] }.compact)
%w[email password].each do |name|
fields[name] = params[name]
end
begin
@account = Account.create(fields)
authorize_account(@account.id)
send_verification_mail(@account, 'Welcome to Stormy Weather!')
redirect '/projects'
rescue Account::EmailTakenError => e
flash[:warning] = "That email address is already taken."
session[:fields] = fields
session[:fields]['terms'] = params['terms']
session[:errors] = { 'email' => 'taken' }
redirect '/sign-up'
rescue Account::InvalidDataError => e
flash[:warning] = "There's a small problem with your info."
session[:fields] = fields
session[:fields]['terms'] = params['terms']
session[:errors] = e.fields
if session[:errors].has_key?('hashed_password')
session[:errors]['password'] = session[:errors].delete('hashed_password')
end
redirect '/sign-up'
end
end
get '/sign-in' do
redirect '/projects' if authorized? && production?
title 'Sign In'
stylesheet 'sign-in'
script 'sign-in'
@email = session.delete(:email)
erb :'sign-in'
end
post '/sign-in' do
if id = Account.check_password(params['email'], params['password'])
authorize_account(id)
if params['remember'] == 'on'
response.set_cookie('remembered', {
:value => current_account.id,
:path => '/',
:expires => Time.now + 2.weeks,
:httponly => true
})
else
response.delete_cookie('remembered')
end
url = session.delete(:original_url) || '/projects'
redirect url
else
flash[:warning] = "Incorrect email address or password."
redirect '/sign-in'
end
end
post '/sign-out' do
deauthorize
redirect '/'
end
get '/forgot-password/?:email?' do |email|
title 'Forgot Password'
script 'forgot-password'
@email = email
erb :'forgot-password'
end
post '/forgot-password' do
if params['email'].blank?
flash[:warning] = "Enter your email address so we can send you a link to reset your password."
redirect '/forgot-password'
elsif send_reset_password_mail(params['email'])
flash[:notice] = "A link to reset your password was sent to #{escape_html(params['email'])}."
redirect '/sign-in'
else
flash[:warning] = "We don't have an account for #{escape_html(params['email'])}."
redirect '/forgot-password'
end
end
# reset password
get '/sign-in/:email/:token' do |email, token|
if id = Account.use_password_reset_token(email, token)
authorize_account(id)
title 'Reset My Password'
stylesheet 'reset-password'
script 'reset-password'
erb :'reset-password'
else
flash[:warning] = "Unknown or expired link to reset password."
redirect '/forgot-password/' + email
end
end
post '/account/reset-password' do
authorize!
current_account.password = params['password']
current_account.save!
redirect '/projects'
end
get '/account' do
authorize!
title 'Account'
stylesheet 'account'
script 'jquery.jeditable'
script 'account'
script 'account-editable'
@account = current_account
erb :account
end
post '/account/password' do
content_type :json
authorize_api!
begin
raise Account::InvalidDataError unless params['new-password'] == params['password-confirmation']
current_account.update_password(params['old-password'], params['new-password'])
ok
rescue Account::IncorrectPasswordError => e
fail('incorrect')
rescue Account::InvalidDataError => e
fail('invalid')
end
end
post '/account/update' do
authorize_api!
begin
current_account.update({ params['id'] => params['value'] })
params['value']
rescue Account::InvalidDataError => e
# This is lame but gives the desired result with jEditable
bad_request
end
end
post '/account/update.json' do
content_type :json
authorize_api!
begin
if params['id'] == 'email'
old_email = current_account.email
new_email = params['value']
current_account.update_email(new_email)
if old_email.downcase != new_email.downcase
send_verification_mail unless current_account.email_verified?
end
else
# decode booleans
if params['id'].match(/_notifications$/)
if params['value'] == 'true'
params['value'] = true
elsif params['value'] == 'false'
params['value'] = false
end
end
current_account.update({ params['id'] => params['value'] })
end
ok
rescue Account::EmailTakenError => e
fail('taken')
rescue Account::InvalidDataError => e
fail('invalid')
end
end
####################
### Verification ###
####################
get '/account/verify/:email/:token' do |email, token|
if Account.verify_email(email, token)
authorize_account(Account.id_from_email(email)) unless authorized?
flash[:notice] = "Your email address has been verified."
redirect '/account'
elsif authorized?
redirect '/account'
else
erb :'verification-failed'
end
end
post '/account/send-email-verification' do
content_type :json
authorize_api!
send_verification_mail
ok
end
end
end

View file

@ -0,0 +1,217 @@
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
module Stormy
class Server < Sinatra::Base
get '/admin' do
admin_authorize!
title "Dashboard"
erb :'admin/dashboard', :layout => :'admin/layout'
end
get '/admin/sign-in' do
title "Sign In"
script 'sign-in'
stylesheet 'sign-in'
erb :'admin/sign-in', :layout => :'admin/layout'
end
post '/admin/sign-in' do
if id = Admin.check_password(params['email'], params['password'])
authorize_admin(id)
redirect session.delete(:original_url) || '/admin'
else
flash[:notice] = "Incorrect email address or password."
redirect '/admin/sign-in'
end
end
post '/admin/sign-out' do
session.delete(:admin_id)
redirect '/admin'
end
get '/admin/password' do
admin_authorize!
title 'Change password'
erb :'admin/password', :layout => :'admin/layout'
end
post '/admin/password' do
admin_authorize!
if params['password'] == params['password_confirmation']
current_admin.password = params['password']
current_admin.save
flash[:notice] = "Password changed."
redirect '/admin'
else
flash[:warning] = "Passwords do not match."
redirect '/admin/password'
end
end
################
### Accounts ###
################
get '/admin/accounts' do
admin_authorize!
mark_last_listing
title "Accounts"
@accounts = Account.fetch_all.sort { |a,b| a.name <=> b.name }
erb :'admin/accounts', :layout => :'admin/layout'
end
get '/admin/account/:email' do |email|
admin_authorize!
if @account = Account.fetch_by_email(email)
mark_last_listing
title "#{@account.name}'s Account"
script 'admin-account'
erb :'admin/account', :layout => :'admin/layout'
else
flash[:notice] = "No account with email #{email}"
redirect last_listing
end
end
get '/admin/sign-in-as/:email' do |email|
admin_authorize!
authorize_account(Account.id_from_email(email))
redirect '/projects'
end
get '/admin/account/:email/delete' do |email|
admin_authorize!
if @account = Account.fetch_by_email(email)
@account.delete!
end
redirect last_listing
end
post '/admin/account/:email' do |email|
admin_authorize!
if @account = Account.fetch_by_email(email)
email_changed = params['new_email'].present? && params['new_email'] != @account.email
fields = params.merge({
'email_verified' => email_changed ? true : @account.email_verified
})
fields.delete('splat')
fields.delete('captures')
fields.delete('email')
new_email = fields.delete('new_email')
new_email = @account.email if new_email.blank?
if new_email != @account.email
begin
@account.update_email(new_email)
rescue Account::EmailTakenError => e
flash[:warning] = "That email address is already taken."
redirect '/admin/account/' + email
end
end
begin
@account.update!(fields, :validate => true)
flash[:notice] = "Account updated."
rescue Account::InvalidDataError => e
flash[:warning] = "Invalid fields: #{e.fields.inspect}"
end
redirect '/admin/account/' + new_email
else
flash[:notice] = "No account with email #{email}"
redirect last_listing
end
end
################
### Projects ###
################
get '/admin/projects' do
admin_authorize!
mark_last_listing
title "Projects"
@projects = Project.fetch_all.sort { |a,b| a.id <=> b.id }
erb :'admin/projects', :layout => :'admin/layout'
end
get '/admin/project/:id' do |id|
admin_authorize!
if @project = Project.fetch(id)
title "Project ##{id}"
title "#{title} (#{@project.name})" if @project.name
script 'admin-project'
erb :'admin/project', :layout => :'admin/layout'
else
flash[:notice] = "No such project (ID #{id})."
redirect last_listing
end
end
get '/admin/project/:id/delete' do |id|
admin_authorize!
Project.delete!(id)
redirect last_listing
end
###########
### FAQ ###
###########
get '/admin/faq' do
admin_authorize!
title 'FAQ'
@faq = faq
if @faq.blank?
@faq = <<-EOT
<p class="question">1. Are you my mother?</p>
<p class="answer">Yes my son.</p>
EOT
end
erb :'admin/faq', :layout => :'admin/layout'
end
post '/admin/faq' do
admin_authorize!
self.faq = params['faq']
flash[:notice] = "FAQ saved."
redirect '/admin/faq'
end
######################
### Admin Accounts ###
######################
get '/admin/admins' do
admin_authorize!
@admins = Admin.fetch_all.sort { |a,b| a.name <=> b.name }
@fields = session.delete(:fields) || {}
title 'Admin Accounts'
stylesheet 'admins'
erb :'admin/admins', :layout => :'admin/layout'
end
post '/admin/admins' do
admin_authorize!
if params['password'] == params['password_confirmation']
admin = Admin.create(params)
flash[:notice] = "Added #{params['name']} (#{params['email']}) as an admin."
else
session[:fields] = params.slice('name', 'email')
flash[:warning] = "Passwords do not match."
end
redirect '/admin/admins'
end
get '/admin/admins/:id/delete' do |id|
admin_authorize!
Admin.delete!(id)
flash[:notice] = "Deleted."
redirect '/admin/admins'
end
end
end

View file

@ -0,0 +1,123 @@
# Copyright 2011 Beta Street Media
module Stormy
class Server < Sinatra::Base
get '/projects' do
authorize!
@projects = current_account.sorted_projects
title 'Projects'
stylesheet 'projects'
script 'projects'
erb :projects
end
get '/project/:id' do |id|
authorize_project!(id)
if current_project.name.blank?
title "Project ID #{id}"
else
title current_project.name
end
stylesheet 'jquery.lightbox-0.5'
script 'jquery.lightbox-0.5'
script 'jquery.dragsort'
stylesheet 'edit-project'
script 'edit-project'
# fuck IE
if request.user_agent.match(/msie/i)
stylesheet 'uploadify'
script 'swfobject'
script 'jquery.uploadify.v2.1.4'
end
@errors = session.delete('errors')
@project = current_project
erb :'edit-project'
end
post '/project/update' do
id = params['id']
if admin_authorized?
current_project(id)
else
authorize_project!(id)
end
begin
current_project.update(params)
flash[:notice] = "Project saved."
rescue Project::InvalidDataError => e
flash[:warning] = "There are some errors with your project."
session['errors'] = e.fields
end
redirect '/project/' + params['id']
end
post '/project/add-photo' do
content_type :json
id = params['id']
if admin_authorized?
current_project(id)
else
authorize_project_api!(id)
end
if photo = current_project.add_photo(params['photo'][:tempfile].path)
ok({
'n' => current_project.count_photos,
'photo' => photo
})
else
fail('limit')
end
end
# fuck IE
post '/uploadify' do
content_type :json
authorize_project_api!(params['id'])
if photo = current_project.add_photo(params['Filedata'][:tempfile].path)
ok({
'n' => current_project.count_photos,
'photo' => photo
})
else
content_type 'text/plain'
bad_request
end
end
post '/project/remove-photo' do
content_type :json
if admin_authorized?
current_project(params['id'])
else
authorize_project_api!(params['id'])
end
current_project.remove_photo(params['photo_id'])
ok({
'photos' => current_project.photos
})
end
post '/project/photo-order' do
content_type :json
id = params['id']
if admin_authorized?
current_project(id)
else
authorize_project_api!(id)
end
current_project.photo_ids = params['order']
current_project.save!
ok
end
end
end

View file

@ -0,0 +1,49 @@
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
module Stormy
class Server < Sinatra::Base
get '/' do
cache_control :public, :must_revalidate, :max_age => 60
stylesheet 'index'
stylesheet 'jquery.lightbox-0.5'
script 'jquery.lightbox-0.5'
script 'index'
erb :index
end
get '/contact' do
cache_control :public, :must_revalidate, :max_age => 60
title 'Contact'
stylesheet 'contact'
script 'contact'
erb :contact
end
post '/contact' do
Pony.mail({
:to => 'info@example.com',
:from => params['email'],
:subject => 'Stormy Weather Contact Form',
:body => params['message']
})
flash[:notice] = "Thanks for contacting us!"
redirect '/contact'
end
get '/terms' do
cache_control :public, :must_revalidate, :max_age => 60
title 'Terms of Service'
erb :terms
end
get '/faq' do
@faq = faq
title 'Frequently Asked Questions'
stylesheet 'faq'
erb :faq
end
end
end

5
lib/stormy/helpers.rb Normal file
View file

@ -0,0 +1,5 @@
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
Dir[File.dirname(__FILE__) + '/helpers/*.rb'].each do |f|
require 'stormy/helpers/' + File.basename(f)
end

View file

@ -0,0 +1,45 @@
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
module Stormy
module Helpers
module Accounts
include Stormy::Models
def send_reset_password_mail(email)
if data = Account.reset_password(email)
body = erb(:'email/reset-password', :layout => :'email/layout', :locals => {
:name => data['name'],
:email => email,
:sign_in_url => url_for('sign-in', email, data['token'])
})
Pony.mail({
:to => email,
:from => 'support@example.com',
:subject => 'Reset your Stormy Weather password',
:headers => { 'Content-Type' => 'text/html' },
:body => body
})
data
end
end
def send_verification_mail(account = current_account, subject = nil)
account.create_email_verification_token
body = erb(:'email/email-verification', :layout => :'email/layout', :locals => {
:name => account.first_name,
:email => account.email,
:url => url_for('account/verify', account.email, account.email_verification_token)
})
Pony.mail({
:to => account.email,
:from => 'support@example.com',
:subject => subject || 'Verify your Stormy Weather account',
:headers => { 'Content-Type' => 'text/html' },
:body => body
})
end
end
end
end

View file

@ -0,0 +1,38 @@
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
module Stormy
module Helpers
module Admin
include Stormy::Models
def num_accounts
Account.count
end
def num_admins
Models::Admin.count
end
def num_projects
Project.count
end
# Used to redirect back to the most recent list of things.
#
# i.e. someone goes to /admin -> /admin/account/foo -> /admin/project/007
# if they delete that project they should go back to /admin/account/foo
#
# however if they go /admin -> /admin/projects -> /admin/project/007
# and then delete that project they should go back to /admin/projects
def last_listing
session.delete(:last_listing) || '/admin'
end
def mark_last_listing(path = request.path_info)
session[:last_listing] = path
end
end
end
end

View file

@ -0,0 +1,108 @@
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
module Stormy
module Helpers
module Authorization
include Stormy::Models
def authorize_account(id)
session[:id] = id
end
def authorized?
if !session[:id] && id = request.cookies['remembered']
authorize_account(id)
end
session[:id] && Account.exists?(session[:id])
end
def authorize!
unless authorized?
session[:original_url] = request.url
redirect '/sign-in'
end
end
def authorize_api!
unless authorized?
content_type 'text/plain'
throw(:halt, not_authorized)
end
end
def deauthorize
session.delete(:id)
response.delete_cookie('remembered')
end
def current_account
if session[:id]
@current_account ||= Account.fetch(session[:id])
end
end
def current_project(id = nil)
if id
@current_project = Project.fetch(id)
else
@current_project
end
end
def project_authorized?
current_project && current_account && current_project.account_id == current_account.id
end
def authorize_project_api!(id)
authorize_api!
current_project(id)
throw(:halt, fail('no such project')) unless current_project
unless project_authorized?
content_type 'text/plain'
throw(:halt, not_authorized)
end
end
def authorize_project!(id)
authorize!
current_project(id)
unless current_project && project_authorized?
flash[:warning] = 'No such project.'
redirect '/projects'
end
end
def authorize_admin(id)
session[:admin_id] = id
end
def deauthorize_admin
session.delete(:admin_id)
end
def admin_authorized?
session[:admin_id] && Models::Admin.exists?(session[:admin_id])
end
def admin_authorize!
unless admin_authorized?
session[:original_url] = request.url
redirect '/admin'
end
end
def admin_authorize_api!
unless admin_authorized?
content_type 'text/plain'
throw(:halt, not_authorized)
end
end
def current_admin
@current_admin ||= Models::Admin.fetch(session[:admin_id])
end
end
end
end

23
lib/stormy/helpers/faq.rb Normal file
View file

@ -0,0 +1,23 @@
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
module Stormy
module Helpers
module FAQ
def faq
redis.get(faq_key)
end
def faq=(new_faq)
redis.set(faq_key, new_faq)
end
private
def faq_key
@faq_key ||= Stormy.key('faq')
end
end
end
end

View file

@ -0,0 +1,65 @@
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
require 'stormy/config'
module Stormy
module Helpers
module Utils
def config
@config ||= Stormy::Config.instance
end
def redis
@redis ||= Redis.new
end
def testing?
ENV['RACK_ENV'] == 'test'
end
def production?
ENV['RACK_ENV'] == 'production'
end
def development?
ENV['RACK_ENV'] == 'development'
end
# JSON responses for API endpoints
def ok(data = nil)
if data.nil?
{ 'status' => 'ok' }
else
{ 'status' => 'ok', 'data' => data }
end.to_json
end
def fail(reason = nil)
if reason.nil?
{ 'status' => 'fail' }
else
{ 'status' => 'fail', 'reason' => reason }
end.to_json
end
def bad_request
[400, "Bad request\n"]
end
def not_authorized
[403, "Not authorized\n"]
end
def base_url
@base_url ||= production? ? "http://dev.example.com:#{settings.port}/" : "http://localhost:#{settings.port}/"
end
def url_for(*args)
"#{base_url}#{args.join('/')}"
end
end
end
end

121
lib/stormy/helpers/views.rb Normal file
View file

@ -0,0 +1,121 @@
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
require 'rdiscount'
module Stormy
module Helpers
module Views
def escape_html(s)
Rack::Utils::escape_html(s)
end
def script(name)
if name.match(/^(https?:)?\/\//)
scripts << name
elsif production?
scripts << "/js-min/#{name}.js"
else
scripts << "/js/#{name}.js"
end
end
def scripts
@page_scripts ||= [
'//ajax.googleapis.com/ajax/libs/jquery/1.7/jquery.min.js',
"/js#{production? ? '-min' : ''}/jquery.placeholder.js",
"/js#{production? ? '-min' : ''}/common.js"
]
end
def stylesheet(name)
if production?
stylesheets << "/css-min/#{name}.css"
else
stylesheets << "/css/#{name}.css"
end
end
def stylesheets
@page_styles ||= ["/css#{production? ? '-min' : ''}/common.css"]
end
def title(title = nil)
@page_title = title if title
@page_title
end
def flash_message
if flash[:notice]
klass = 'notice'
message = flash[:notice]
elsif flash[:warning]
klass = 'warning'
message = flash[:warning]
elsif flash[:error]
klass = 'error'
message = flash[:error]
else
klass = flash.keys.first
message = flash[klass] if klass
end
if message
"<div id=\"flash\" class=\"#{klass}\">#{message}</div>"
end
end
def format_dollars(amount, currency = 'CAD')
'%s $%.2f' % [currency, amount / 100.0]
end
def format_date(date)
date.strftime("%B %e, %Y")
end
def format_time(time)
time.strftime('%B %e, %Y %l:%M %p')
end
def pad(n)
n < 10 ? "0#{n}" : "#{n}"
end
def format_duration(duration)
mins = duration / 60
secs = duration % 60
"#{pad(mins)}:#{pad(secs)}"
end
def ordinal_day(day)
th = case day
when 1
'st'
when 2
'nd'
when 3
'rd'
when 21
'st'
when 22
'nd'
when 23
'rd'
when 31
'st'
else
'th'
end
"#{day}#{th}"
end
def format_percent(percent)
"#{(100 * percent).to_i}%"
end
def markdown(s)
RDiscount.new(s.to_s).to_html
end
end
end
end

7
lib/stormy/models.rb Normal file
View file

@ -0,0 +1,7 @@
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
require 'stormy/models/base'
Dir[File.dirname(__FILE__) + '/models/*.rb'].each do |f|
require 'stormy/models/' + File.basename(f)
end

View file

@ -0,0 +1,226 @@
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
require 'bcrypt'
require 'uuid'
module Stormy
module Models
class Account < Base
class EmailTakenError < RuntimeError; end
class IncorrectPasswordError < RuntimeError; end
name 'account'
field :id, :required => true
field :email, :type => :email, :required => true
field :first_name, :required => true, :updatable => true
field :last_name, :required => true, :updatable => true
field :phone, :type => :phone, :updatable => true
field :hashed_password, :required => true
field :password
field :created_timestamp, :type => :integer
field :email_verification_token, :nullify_if_blank => true
field :email_verified?
field :password_reset_token, :nullify_if_blank => true
@@account_email_index_key = Stormy.key('index:account-email')
### Class Methods
def self.check_password(email, password)
id = id_from_email(email)
key = self.key(id)
if key
hashed_password = BCrypt::Password.new(redis.hget(key, 'hashed_password'))
id if hashed_password == password
end
end
def self.email_taken?(email)
!! redis.hget(@@account_email_index_key, email.to_s.strip.downcase)
end
def self.fetch_by_email(email)
if id = id_from_email(email)
fetch(id)
end
end
def self.reset_password(email)
if key = key_from_email(email)
token = redis.hget(key, 'password_reset_token')
if token.blank?
token = UUID.generate
redis.hset(key, 'password_reset_token', token)
end
{ 'name' => redis.hget(key, 'first_name'),
'token' => token
}
end
end
def self.use_password_reset_token(email, token)
if id = id_from_email(email)
key = key(id)
expected_token = redis.hget(key, 'password_reset_token')
if token == expected_token
redis.hdel(key, 'password_reset_token')
id
end
end
end
def self.id_from_email(email)
redis.hget(@@account_email_index_key, email.strip.downcase)
end
def self.verify_email(email, token)
if key = key_from_email(email)
expected_token = redis.hget(key, 'email_verification_token')
verified = token == expected_token
if verified
redis.hdel(key, 'email_verification_token')
redis.hset(key, 'email_verified', true)
end
verified
end
end
def self.email_verified?(email)
if key = key_from_email(email)
redis.hget(key, 'email_verified') == 'true'
end
end
### Private Class Methods
def self.key_from_email(email)
key(id_from_email(email))
end
private_class_method :key_from_email
### Instance Methods
def initialize(fields = {}, options = {})
super(fields, options)
if fields['hashed_password']
self.hashed_password = BCrypt::Password.new(fields['hashed_password'])
else
self.password = fields['password']
end
end
def create
raise EmailTakenError if email_taken?
# new accounts get an id and timestamp
self.id = UUID.generate unless id.present?
self.created_timestamp = Time.now.to_i
super
create_email_verification_token
# add to index
redis.hset(@@account_email_index_key, email.downcase, id)
self
end
def delete!
project_ids.each { |id| Project.delete!(id) }
super
redis.hdel(@@account_email_index_key, email.strip.downcase)
end
def email_taken?(email = @email)
self.class.email_taken?(email)
end
def create_email_verification_token
self.email_verification_token ||= UUID.generate
redis.hset(key, 'email_verification_token', email_verification_token)
email_verification_token
end
def password
@password ||= BCrypt::Password.new(hashed_password)
end
def password=(new_password)
if new_password.present?
self.hashed_password = BCrypt::Password.create(new_password)
@password = nil
end
end
def name
"#{first_name} #{last_name}"
end
def count_projects
redis.scard(project_ids_key)
end
def project_ids
redis.smembers(project_ids_key)
end
def projects
project_ids.map { |pid| Project.fetch(pid) }
end
def sorted_projects
@sorted_projects ||= projects.sort { |a,b| a.created_timestamp <=> b.created_timestamp }
end
def add_project_id(id)
redis.sadd(project_ids_key, id)
end
def remove_project_id(id)
redis.srem(project_ids_key, id)
end
def update_email(new_email)
new_email = new_email.strip
if email != new_email
raise EmailTakenError if new_email.downcase != email.downcase && email_taken?(new_email)
raise InvalidDataError.new({ 'email' => 'invalid' }) unless field_valid?('email', new_email)
if email.downcase != new_email.downcase
self.email_verified = false
redis.hdel(@@account_email_index_key, email.downcase)
redis.hset(@@account_email_index_key, new_email.downcase, id)
end
self.email = new_email
save!
end
end
def update_password(old_password, new_password)
hashed_password = BCrypt::Password.new(redis.hget(key, 'hashed_password'))
raise IncorrectPasswordError unless hashed_password == old_password
raise InvalidDataError.new({ 'password' => 'missing' }) if new_password.blank?
redis.hset(key, 'hashed_password', BCrypt::Password.create(new_password))
self.password = new_password
end
private
def project_ids_key
@project_ids_key ||= "#{key}:project-ids"
end
end
end
end

124
lib/stormy/models/admin.rb Normal file
View file

@ -0,0 +1,124 @@
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
require 'bcrypt'
require 'uuid'
module Stormy
module Models
class Admin < Base
class EmailTakenError < RuntimeError; end
class IncorrectPasswordError < RuntimeError; end
name 'admin'
field :id, :required => true
field :name, :required => true, :updatable => true
field :email, :type => :email, :required => true
field :hashed_password, :required => true
field :password
@@admin_email_index_key = Stormy.key('index:admin-email')
### Class Methods
def self.key_from_email(email)
key(id_from_email(email))
end
def self.check_password(email, password)
id = id_from_email(email)
key = self.key(id)
if key
hashed_password = BCrypt::Password.new(redis.hget(key, 'hashed_password'))
id if hashed_password == password
end
end
def self.email_taken?(email)
!! redis.hget(@@admin_email_index_key, email.to_s.strip.downcase)
end
def self.fetch_by_email(email)
key = key_from_email(email)
new(redis.hgetall(key)) if key
end
def self.id_from_email(email)
redis.hget(@@admin_email_index_key, email.strip.downcase)
end
### Instance Methods
def initialize(fields = {}, options = {})
super(fields, options)
if fields['hashed_password']
self.hashed_password = BCrypt::Password.new(fields['hashed_password'])
else
self.password = fields['password']
end
end
def create
raise EmailTakenError if email_taken?
self.id = UUID.generate unless id.present?
super
# add to index
redis.hset(@@admin_email_index_key, @email.downcase, @id)
self
end
def delete!
if super
redis.hdel(@@admin_email_index_key, @email.strip.downcase)
end
end
def email_taken?(email = @email)
self.class.email_taken?(email)
end
def password
@password ||= BCrypt::Password.new(hashed_password)
end
def password=(new_password)
if new_password.present?
self.hashed_password = BCrypt::Password.create(new_password)
@password = nil
end
end
def update_email(new_email)
new_email = new_email.strip
if email != new_email
raise EmailTakenError if new_email.downcase != email.downcase && email_taken?(new_email)
raise InvalidDataError.new({ 'email' => 'invalid' }) unless field_valid?('email', new_email)
if email.downcase != new_email.downcase
redis.hdel(@@admin_email_index_key, email.downcase)
redis.hset(@@admin_email_index_key, new_email.downcase, id)
end
self.email = new_email
save!
end
end
def update_password(old_password, new_password)
hashed_password = BCrypt::Password.new(redis.hget(key, 'hashed_password'))
raise IncorrectPasswordError unless hashed_password == old_password
raise InvalidDataError.new({ 'password' => 'missing' }) if new_password.blank?
redis.hset(key, 'hashed_password', BCrypt::Password.create(new_password))
self.password = new_password
end
end
end
end

404
lib/stormy/models/base.rb Normal file
View file

@ -0,0 +1,404 @@
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
require 'json'
require 'redis'
require 'uuid'
module Stormy
module Models
class Base
class InvalidDataError < RuntimeError
attr_reader :fields
def initialize(invalid_fields = {})
@fields = invalid_fields
end
end
def self.clean_number(number)
number.gsub(/[^\d]/, '').sub(/^1/, '')
end
# Allows any 10 digit number in North America, or an empty field (for account creation).
PhoneNumberValidator = proc do |number|
if number.present?
clean_number(number).length == 10
else
true
end
end
# Liberal email address regex
EmailAddressValidator = proc { |email| email =~ /^[^@]+@[^.@]+(\.[^.@]+)+$/ }
# Only changed fields are persisted on save
attr_reader :changed_fields
#####################
### Class Methods ###
#####################
@@redis = Redis.new
def self.redis
@@redis
end
# Define or retrieve the name of this model.
def self.name(name = nil)
if name
@model_name = name
end
@model_name
end
# Hash of all fields.
def self.fields
@fields ||= {}
end
# Define fields like so:
#
# field :id, :type => :integer, :required => true
# field :name, :required => true, :updatable => true
# field :verified?
#
# Defaults: {
# :type => :string,
# :required => false,
# :updatable => false,
# :validator => nil, # with some exceptions
# :default => {},
# :nullify_if_blank => false
# }
#
# Types: :string, :integer, :boolean, :json, as well as
# :email and :phone which are string aliases with the
# appropriate validations. String fields have an option
# :nullify_if_blank that will initialize and set fields
# to `nil` if they are empty.
#
# If an `integer` is required it must be greater than zero.
# The required option has no effect on boolean fields.
#
# Fields with names ending with question mark are boolean.
#
# JSON fields accept a :default option used to initialize
# a JSON field, and also when a parse fails.
#
# Attribute accessors are defined for each field and boolean
# fields get a predicate method as well, e.g. verified?
#
# Changed fields are tracked and only changed fields are
# persisted on a `save`.
#
def self.field(name, options = {})
if name.to_s.ends_with?('?')
options[:type] = :boolean
name = name.to_s[0..-2]
end
name = name.to_sym
options[:type] ||= :string
case options[:type]
when :email
options[:validator] ||= EmailAddressValidator
options[:type] = :string
when :phone
options[:validator] ||= PhoneNumberValidator
options[:type] = :string
when :json
options[:default] ||= {}
end
fields[name] = options
define_method(name) do
instance_variable_get("@#{name}")
end
case options[:type]
when :string
define_method("#{name}=") do |value|
s =
if options[:nullify_if_blank] && value.blank?
nil
else
value.to_s.strip
end
instance_variable_set("@#{name}", s)
changed_fields[name] = s
end
when :integer
define_method("#{name}=") do |value|
i = value.to_i
instance_variable_set("@#{name}", i)
changed_fields[name] = i
end
when :boolean
define_method("#{name}=") do |value|
b = value == 'true' || value == true
instance_variable_set("@#{name}", b)
changed_fields[name] = b
end
define_method("#{name}?") do
instance_variable_get("@#{name}")
end
when :json
define_method(name) do
unless value = instance_variable_get("@#{name}")
value = options[:default].dup
send("#{name}=", value)
end
value
end
define_method("#{name}=") do |value|
obj =
if value.is_a?(String)
if value.length > 0
JSON.parse(value)
else
options[:default].dup
end
else
value
end
instance_variable_set("@#{name}", obj)
changed_fields[name] = obj
end
else
define_method("#{name}=") do |value|
instance_variable_set("@#{name}", value)
changed_fields[name] = value
end
end
end
# internal
def self.model_ids_key
@model_ids_key ||= Stormy.key("#{@model_name}-ids")
end
def self.create(fields = {})
new(fields).create
end
def self.delete!(id)
if obj = fetch(id)
obj.delete!
end
end
def self.exists?(id)
redis.sismember(model_ids_key, id)
end
def self.fetch(id)
if id && exists?(id)
new(redis.hgetall(key(id)), :fetched => true)
end
end
def self.fetch_all
list_ids.map { |id| fetch(id) }
end
def self.key(id)
Stormy.key(@model_name, id) if id
end
def self.list_ids
redis.smembers(model_ids_key)
end
def self.count
redis.scard(model_ids_key)
end
### Instance Methods
attr_accessor :redis
def initialize(fields = {}, options = {})
self.redis = self.class.redis
fields = fields.symbolize_keys
field_names.each do |name|
send("#{name}=", fields[name])
end
# no changed fields yet if we have been fetched
if options[:fetched]
@changed_fields = {}
end
end
def create
# raises if invalid
save
add_to_index
self
end
def delete!
if redis.srem(self.class.model_ids_key, id)
redis.del(key)
end
end
def reload!
initialize(redis.hgetall(key))
self
end
# Convenient defaults for performing safe updates.
def update!(fields, options = {})
options[:validate] = false unless options.has_key?(:validate)
options[:all] = true unless options.has_key?(:all)
update(fields, options)
end
# The `update` method only updates fields marked updatable.
# Unless you pass in :all => true, then all fields are
# updated.
#
# There's also a :validate flag.
#
def update(fields, options = {})
options[:validate] = true unless options.has_key?(:validate)
fields.each do |name, value|
if options[:all] || field_updatable?(name)
send("#{name}=", value)
end
end
if options[:validate]
save
else
save!
end
end
def save
validate
save!
end
def save!
# always update JSON fields because they can be updated without our knowledge
field_names.each do |name|
if field_type(name) == :json
changed_fields[name] = send(name)
end
end
fields = changed_fields.map do |name, value|
if field_type(name) == :json && !value.is_a?(String)
[name, JSON.fast_generate(value || field_default(name))]
else
[name, value]
end
end
if fields.length > 0
redis.hmset(key, *fields.flatten)
end
@changed_fields = {}
end
def validate
invalid_fields = field_names.inject({}) do |fields, name|
if field_validates?(name)
result = validate_field(name, send(name))
fields[name] = result[:reason] unless result[:valid]
end
fields
end
if invalid_fields.length > 0
raise InvalidDataError.new(invalid_fields.stringify_keys)
end
end
private
def key
@key ||= self.class.key(self.id)
end
def add_to_index
redis.sadd(self.class.model_ids_key, self.id)
end
def changed_fields
@changed_fields ||= {}
end
def clean_number(number)
self.class.clean_number(number)
end
def field_names
self.class.fields.keys
end
def field_type(name)
self.class.fields[name.to_sym][:type]
end
def field_updatable?(name)
self.class.fields[name.to_sym][:updatable]
end
def validate_field(name, value)
valid = true
reason = nil
field = self.class.fields[name.to_sym]
type = field[:type]
if field[:required]
case
when type == :string && value.blank?
valid = false
reason = 'missing'
when type == :integer && value.to_i <= 0
valid = false
reason = 'missing'
when type == :json && value.blank?
valid = false
reason = 'missing'
end
end
if valid && validator = field[:validator]
valid = validator.call(value)
reason = 'invalid'
end
{ :valid => valid, :reason => reason }
end
def field_valid?(name, value)
result = validate_field(name, value)
result[:valid]
end
def field_validates?(name)
field = self.class.fields[name.to_sym]
field[:required] || field[:validator]
end
def field_default(name)
self.class.fields[name][:default]
end
end
end
end

View file

@ -0,0 +1,162 @@
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
require 'digest/sha1'
require 'fileutils'
require 'RMagick'
module Stormy
module Models
class Project < Base
# max width or height in pixels
MaxPhotoSize = 1200
MaxPhotos = 10
name 'project'
field :id, :required => true
field :name, :required => true, :updatable => true
field :account_id
field :created_timestamp, :type => :integer, :required => true
field :fizzled_timestamp, :type => :integer
field :funded_timestamp, :type => :integer
field :photo_ids, :type => :json, :default => []
@@project_name_index_key = Stormy.key('index:project-name')
def self.fetch_by_name(name)
if id = id_from_name(name)
fetch(id)
end
end
def self.id_from_name(name)
redis.hget(@@project_name_index_key, name.strip.downcase)
end
def create
self.id = UUID.generate unless id.present?
self.created_timestamp = Time.now.to_i
super
# add to index
redis.hset(@@project_name_index_key, name.downcase, id)
account.add_project_id(id) if account
self
end
def delete!
if super
remove_all_photos!
account.remove_project_id(id) if account
redis.hdel(@@project_name_index_key, name.strip.downcase)
end
end
def funded?
funded_timestamp > 0
end
def funded!
self.funded_timestamp = Time.now.to_i
save!
end
def fizzled?
fizzled_timestamp > 0
end
def fizzled!
self.fizzled_timestamp = Time.now.to_i
save!
end
def count_photos
photo_ids.length
end
def add_photo(path)
unless count_photos >= MaxPhotos
photo = Magick::Image.read(path).first
photo.auto_orient!
photo.change_geometry("#{MaxPhotoSize}x#{MaxPhotoSize}>") { |cols, rows, img| img.resize!(cols, rows) }
photo.format = 'jpg'
FileUtils.mkdir_p(photo_dir) unless File.exists?(photo_dir)
photo_id = Digest::SHA1.hexdigest(photo.to_blob)
photo.write(photo_path(photo_id)) { self.quality = 80 }
photo_ids << photo_id
save!
photo_data(photo_id)
end
end
def remove_photo(photo_id)
path = photo_path(photo_id)
if i = photo_ids.index(photo_id)
photo_ids.delete_at(i)
end
FileUtils.rm(path) if File.exists?(path) && !photo_ids.include?(photo_id)
save!
end
def photo_paths
photo_ids.map { |id| photo_path(id) }
end
def photo_urls
photo_ids.map { |photo_id| "/photos/#{id}/#{photo_id}.jpg" }
end
def photo_url(photo_id)
"/photos/#{id}/#{photo_id}.jpg"
end
def photo_data(photo_id)
{
'id' => photo_id,
'url' => photo_url(photo_id)
}
end
def photos
photo_ids.map { |id| photo_data(id) }
end
def account
if account_id
@account ||= Account.fetch(account_id)
end
end
private
def photo_dir
File.join(Stormy::PhotoDir, @id)
end
def photo_path(id)
File.join(photo_dir, "#{id}.jpg")
end
def photos_key
"#{key}:photos"
end
def remove_all_photos!
FileUtils.rm_rf(photo_dir) if File.exists?(photo_dir)
self.photo_ids = []
end
end
end
end

93
lib/stormy/server.rb Normal file
View file

@ -0,0 +1,93 @@
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
require 'time'
require 'sinatra'
require 'sinatra/cookie_thief'
require 'sinatra/flash'
require 'erubis'
require 'json'
require 'pony'
require 'redis'
require 'redis-store'
require 'uuid'
# Ruby extensions
require 'hash-ext'
require 'stormy/models'
require 'stormy/controllers'
require 'stormy/helpers'
module Stormy
class Server < Sinatra::Base
set :port, 5000
configure :production do
enable :dump_errors
# compress responses
use Rack::Deflater
# cache static files for an hour
set :static_cache_control, [ :must_revalidate, :max_age => 60 ]
end
enable :logging
# serve static files from /public, views from /views
set :public_folder, File.dirname(__FILE__) + '/../../public'
set :views, File.dirname(__FILE__) + '/../../views'
# Automatically escape HTML
set :erb, :escape_html => true
# disable Rack::Protection, JsonCsrf breaks things
disable :protection
# disable cookies for static files
register Sinatra::CookieThief
register Sinatra::Flash
use Rack::Session::Redis, {
:httponly => true,
:secret => '38066be6a9d388626e045be2351d26918608d53c',
:expire_after => 8.hours
}
helpers Helpers::Accounts
helpers Helpers::Admin
helpers Helpers::Authorization
helpers Helpers::FAQ
helpers Helpers::Utils
helpers Helpers::Views
not_found do
erb :'not-found'
end
error do
if production?
body = erb(:'email/error-notification', :layout => false, :locals => {
:account => current_account,
:project => current_project,
:admin => current_admin,
:error => env['sinatra.error']
})
Pony.mail({
:to => 'admin@example.com',
:from => 'info@example.com',
:subject => "[stormy] #{@error.class}: #{@error.message}",
:headers => { 'Content-Type' => 'text/html' },
:body => body
})
end
erb :error
end
end
end

53
public/css/account.css Normal file
View file

@ -0,0 +1,53 @@
table#account
{ width: 70%
; margin: auto
; border-spacing: 0 0.7em
}
th
{ text-align: right
; padding-right: 1em
; vertical-align: top
}
th, td { color: #405e83 }
hr
{ width: 70%
; margin: 2em auto
}
#taken, #invalid
{ font-weight: bold
; color: #600
; text-align: center
; padding: 0.3em 0.5em
}
div.indented p,
div.indented form,
p.indented
{ margin-left: 10em }
#content a { color: #405e83 }
.verified { color: #4db269; float: right }
.unverified { color: #aa0000 }
#sending-email-verification
{ display: none }
table#change-password
{ display: none
; margin-left: 10em
}
.spinner
{ display: none
; margin: 0 0.5em
}
#password-changed
{ display: none
; margin-left: 10em
}

78
public/css/admin.css Normal file
View file

@ -0,0 +1,78 @@
body
{ font-size: 1.2em
; font-family: 'Helvetica Neue', Roboto, Helvetica, Verdana, sans-serif
}
h1 { float: left }
#sign-in
{ text-align: center
; width: 50%
; margin: auto
; padding: 1em
; background-color: #d0d0d0
; clear: both
}
#sign-out
{ float: right
; font-size: 1.0em
}
#content
{ float: right
; text-align: left
; width: 70%
; margin-right: 2em
; padding-left: 2em
; border-left: solid 2px #ddd
}
#nav
{ width: 200px
; list-style-type: none
; float: none !important
}
#nav li a { text-decoration: underline }
.section { border-bottom: solid 1px #ddd }
.section:last-child { border-bottom: none }
.subtle
{ color: #bbb
; margin-left: 20em
}
#flash
{ border: solid 1px #bbb
; font-weight: bold
; border: solid 1px #bbb
; font-weight: bold
; padding: 0.7em 0.4em
; margin: 1em auto
; width: 70%
; font-size: 1.2em
; text-align: center
; clear: left
; border-radius: 5px
}
.notice { background-color: #d3edc5 }
.warning { background-color: #ffc }
#flash.error { background-color: #fcc }
/* Accounts */
#accounts { border-collapse: collapse }
#accounts th { background-color: #def }
#accounts tr:nth-child(even) { background-color: #eee }
#accounts td,
#accounts th
{ text-align: center
; padding: 0.2em 0.3em
}
td.name { text-align: left }

567
public/css/bootstrap-responsive.css vendored Normal file
View file

@ -0,0 +1,567 @@
/*!
* Bootstrap Responsive v2.0.0
*
* Copyright 2012 Twitter, Inc
* Licensed under the Apache License v2.0
* http://www.apache.org/licenses/LICENSE-2.0
*
* Designed and built with all the love in the world @twitter by @mdo and @fat.
*/
.hidden {
display: none;
visibility: hidden;
}
@media (max-width: 480px) {
.nav-collapse {
-webkit-transform: translate3d(0, 0, 0);
}
.page-header h1 small {
display: block;
line-height: 18px;
}
input[class*="span"],
select[class*="span"],
textarea[class*="span"],
.uneditable-input {
display: block;
width: 100%;
height: 28px;
/* Make inputs at least the height of their button counterpart */
/* Makes inputs behave like true block-level elements */
-webkit-box-sizing: border-box;
/* Older Webkit */
-moz-box-sizing: border-box;
/* Older FF */
-ms-box-sizing: border-box;
/* IE8 */
box-sizing: border-box;
/* CSS3 spec*/
}
.input-prepend input[class*="span"], .input-append input[class*="span"] {
width: auto;
}
input[type="checkbox"], input[type="radio"] {
border: 1px solid #ccc;
}
.form-horizontal .control-group > label {
float: none;
width: auto;
padding-top: 0;
text-align: left;
}
.form-horizontal .controls {
margin-left: 0;
}
.form-horizontal .control-list {
padding-top: 0;
}
.form-horizontal .form-actions {
padding-left: 10px;
padding-right: 10px;
}
.modal {
position: absolute;
top: 10px;
left: 10px;
right: 10px;
width: auto;
margin: 0;
}
.modal.fade.in {
top: auto;
}
.modal-header .close {
padding: 10px;
margin: -10px;
}
.carousel-caption {
position: static;
}
}
@media (max-width: 768px) {
.container {
width: auto;
padding: 0 20px;
}
.row-fluid {
width: 100%;
}
.row {
margin-left: 0;
}
.row > [class*="span"], .row-fluid > [class*="span"] {
float: none;
display: block;
width: auto;
margin: 0;
}
}
@media (min-width: 768px) and (max-width: 980px) {
.row {
margin-left: -20px;
*zoom: 1;
}
.row:before, .row:after {
display: table;
content: "";
}
.row:after {
clear: both;
}
[class*="span"] {
float: left;
margin-left: 20px;
}
.span1 {
width: 42px;
}
.span2 {
width: 104px;
}
.span3 {
width: 166px;
}
.span4 {
width: 228px;
}
.span5 {
width: 290px;
}
.span6 {
width: 352px;
}
.span7 {
width: 414px;
}
.span8 {
width: 476px;
}
.span9 {
width: 538px;
}
.span10 {
width: 600px;
}
.span11 {
width: 662px;
}
.span12, .container {
width: 724px;
}
.offset1 {
margin-left: 82px;
}
.offset2 {
margin-left: 144px;
}
.offset3 {
margin-left: 206px;
}
.offset4 {
margin-left: 268px;
}
.offset5 {
margin-left: 330px;
}
.offset6 {
margin-left: 392px;
}
.offset7 {
margin-left: 454px;
}
.offset8 {
margin-left: 516px;
}
.offset9 {
margin-left: 578px;
}
.offset10 {
margin-left: 640px;
}
.offset11 {
margin-left: 702px;
}
.row-fluid {
width: 100%;
*zoom: 1;
}
.row-fluid:before, .row-fluid:after {
display: table;
content: "";
}
.row-fluid:after {
clear: both;
}
.row-fluid > [class*="span"] {
float: left;
margin-left: 2.762430939%;
}
.row-fluid > [class*="span"]:first-child {
margin-left: 0;
}
.row-fluid .span1 {
width: 5.801104972%;
}
.row-fluid .span2 {
width: 14.364640883%;
}
.row-fluid .span3 {
width: 22.928176794%;
}
.row-fluid .span4 {
width: 31.491712705%;
}
.row-fluid .span5 {
width: 40.055248616%;
}
.row-fluid .span6 {
width: 48.618784527%;
}
.row-fluid .span7 {
width: 57.182320438000005%;
}
.row-fluid .span8 {
width: 65.74585634900001%;
}
.row-fluid .span9 {
width: 74.30939226%;
}
.row-fluid .span10 {
width: 82.87292817100001%;
}
.row-fluid .span11 {
width: 91.436464082%;
}
.row-fluid .span12 {
width: 99.999999993%;
}
input.span1, textarea.span1, .uneditable-input.span1 {
width: 32px;
}
input.span2, textarea.span2, .uneditable-input.span2 {
width: 94px;
}
input.span3, textarea.span3, .uneditable-input.span3 {
width: 156px;
}
input.span4, textarea.span4, .uneditable-input.span4 {
width: 218px;
}
input.span5, textarea.span5, .uneditable-input.span5 {
width: 280px;
}
input.span6, textarea.span6, .uneditable-input.span6 {
width: 342px;
}
input.span7, textarea.span7, .uneditable-input.span7 {
width: 404px;
}
input.span8, textarea.span8, .uneditable-input.span8 {
width: 466px;
}
input.span9, textarea.span9, .uneditable-input.span9 {
width: 528px;
}
input.span10, textarea.span10, .uneditable-input.span10 {
width: 590px;
}
input.span11, textarea.span11, .uneditable-input.span11 {
width: 652px;
}
input.span12, textarea.span12, .uneditable-input.span12 {
width: 714px;
}
}
@media (max-width: 980px) {
body {
padding-top: 0;
}
.navbar-fixed-top {
position: static;
margin-bottom: 18px;
}
.navbar-fixed-top .navbar-inner {
padding: 5px;
}
.navbar .container {
width: auto;
padding: 0;
}
.navbar .brand {
padding-left: 10px;
padding-right: 10px;
margin: 0 0 0 -5px;
}
.navbar .nav-collapse {
clear: left;
}
.navbar .nav {
float: none;
margin: 0 0 9px;
}
.navbar .nav > li {
float: none;
}
.navbar .nav > li > a {
margin-bottom: 2px;
}
.navbar .nav > .divider-vertical {
display: none;
}
.navbar .nav > li > a, .navbar .dropdown-menu a {
padding: 6px 15px;
font-weight: bold;
color: #999999;
-webkit-border-radius: 3px;
-moz-border-radius: 3px;
border-radius: 3px;
}
.navbar .dropdown-menu li + li a {
margin-bottom: 2px;
}
.navbar .nav > li > a:hover, .navbar .dropdown-menu a:hover {
background-color: #222222;
}
.navbar .dropdown-menu {
position: static;
top: auto;
left: auto;
float: none;
display: block;
max-width: none;
margin: 0 15px;
padding: 0;
background-color: transparent;
border: none;
-webkit-border-radius: 0;
-moz-border-radius: 0;
border-radius: 0;
-webkit-box-shadow: none;
-moz-box-shadow: none;
box-shadow: none;
}
.navbar .dropdown-menu:before, .navbar .dropdown-menu:after {
display: none;
}
.navbar .dropdown-menu .divider {
display: none;
}
.navbar-form, .navbar-search {
float: none;
padding: 9px 15px;
margin: 9px 0;
border-top: 1px solid #222222;
border-bottom: 1px solid #222222;
-webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 1px 0 rgba(255, 255, 255, 0.1);
-moz-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 1px 0 rgba(255, 255, 255, 0.1);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 1px 0 rgba(255, 255, 255, 0.1);
}
.navbar .nav.pull-right {
float: none;
margin-left: 0;
}
.navbar-static .navbar-inner {
padding-left: 10px;
padding-right: 10px;
}
.btn-navbar {
display: block;
}
.nav-collapse {
overflow: hidden;
height: 0;
}
}
@media (min-width: 980px) {
.nav-collapse.collapse {
height: auto !important;
}
}
@media (min-width: 1200px) {
.row {
margin-left: -30px;
*zoom: 1;
}
.row:before, .row:after {
display: table;
content: "";
}
.row:after {
clear: both;
}
[class*="span"] {
float: left;
margin-left: 30px;
}
.span1 {
width: 70px;
}
.span2 {
width: 170px;
}
.span3 {
width: 270px;
}
.span4 {
width: 370px;
}
.span5 {
width: 470px;
}
.span6 {
width: 570px;
}
.span7 {
width: 670px;
}
.span8 {
width: 770px;
}
.span9 {
width: 870px;
}
.span10 {
width: 970px;
}
.span11 {
width: 1070px;
}
.span12, .container {
width: 1170px;
}
.offset1 {
margin-left: 130px;
}
.offset2 {
margin-left: 230px;
}
.offset3 {
margin-left: 330px;
}
.offset4 {
margin-left: 430px;
}
.offset5 {
margin-left: 530px;
}
.offset6 {
margin-left: 630px;
}
.offset7 {
margin-left: 730px;
}
.offset8 {
margin-left: 830px;
}
.offset9 {
margin-left: 930px;
}
.offset10 {
margin-left: 1030px;
}
.offset11 {
margin-left: 1130px;
}
.row-fluid {
width: 100%;
*zoom: 1;
}
.row-fluid:before, .row-fluid:after {
display: table;
content: "";
}
.row-fluid:after {
clear: both;
}
.row-fluid > [class*="span"] {
float: left;
margin-left: 2.564102564%;
}
.row-fluid > [class*="span"]:first-child {
margin-left: 0;
}
.row-fluid .span1 {
width: 5.982905983%;
}
.row-fluid .span2 {
width: 14.529914530000001%;
}
.row-fluid .span3 {
width: 23.076923077%;
}
.row-fluid .span4 {
width: 31.623931624%;
}
.row-fluid .span5 {
width: 40.170940171000005%;
}
.row-fluid .span6 {
width: 48.717948718%;
}
.row-fluid .span7 {
width: 57.264957265%;
}
.row-fluid .span8 {
width: 65.81196581200001%;
}
.row-fluid .span9 {
width: 74.358974359%;
}
.row-fluid .span10 {
width: 82.905982906%;
}
.row-fluid .span11 {
width: 91.45299145300001%;
}
.row-fluid .span12 {
width: 100%;
}
input.span1, textarea.span1, .uneditable-input.span1 {
width: 60px;
}
input.span2, textarea.span2, .uneditable-input.span2 {
width: 160px;
}
input.span3, textarea.span3, .uneditable-input.span3 {
width: 260px;
}
input.span4, textarea.span4, .uneditable-input.span4 {
width: 360px;
}
input.span5, textarea.span5, .uneditable-input.span5 {
width: 460px;
}
input.span6, textarea.span6, .uneditable-input.span6 {
width: 560px;
}
input.span7, textarea.span7, .uneditable-input.span7 {
width: 660px;
}
input.span8, textarea.span8, .uneditable-input.span8 {
width: 760px;
}
input.span9, textarea.span9, .uneditable-input.span9 {
width: 860px;
}
input.span10, textarea.span10, .uneditable-input.span10 {
width: 960px;
}
input.span11, textarea.span11, .uneditable-input.span11 {
width: 1060px;
}
input.span12, textarea.span12, .uneditable-input.span12 {
width: 1160px;
}
.thumbnails {
margin-left: -30px;
}
.thumbnails > li {
margin-left: 30px;
}
}

3932
public/css/bootstrap.css vendored Executable file

File diff suppressed because it is too large Load diff

186
public/css/common.css Normal file
View file

@ -0,0 +1,186 @@
body
{ font-family: 'Helvetica Neue', Helvetica, Verdana, Tahoma, sans-serif
; line-height: 1.4em
}
:focus { outline: none }
img, fieldset { border: none }
ul, ol, li { list-style: none }
a { color: #405e83 }
a:hover { color: #000 }
#wrapper
{ width: 90%
; max-width: 1000px
; margin: 0 auto
; padding: 0
}
#header { margin: 2em 0 0.7em }
#nav
{ margin: 2.5em 0 0
; float: right
; font-size: 0.9em
; color: #405e83
}
#nav.authorized { margin-top: 1em }
#nav a
{ color: #405e83
; text-decoration: none
}
#nav a:hover { color: #8db051 }
#content
{ background-color: #e4e8ed
; padding-bottom: 2em
}
.section-heading
{ margin: 0
; background-color: #405e83
; padding: 1.1em
; font-weight: normal
; color: #fff
; display: inline-block
; border-top-right-radius: 5px
; border-bottom-right-radius: 5px
}
.section-heading.top { border-top-right-radius: 0 }
.section
{ clear: left
; width: 80%
; margin: auto
; padding: 1em 0 2em
}
.clear { clear: both }
#flash
{ border: solid 1px #bbb
; font-weight: bold
; padding: 0.7em 0.4em
; margin: 1em auto
; width: 70%
; font-size: 1.2em
; text-align: center
; clear: left
; border-radius: 5px
}
.notice { background-color: #d3edc5 }
.warning { background-color: #ffc }
#flash.error { background-color: #fcc }
/* for input fields and validation messages */
input.error, p.error { background-color: #ffc }
button#hide-flash
{ background-color: #092
; border-color: #061
; float: right
; padding: 0.3em 0.5em
; margin-top: -0.3em
}
#flash.error button#hide-flash
{ background-color: #a00
; border-color: #600
}
input[type="email"],
input[type="password"],
input[type="phone"],
input[type="tel"],
input[type="text"],
input[type="url"]
{ font-family: 'Helvetica Neue', Helvetica, Verdana, Tahoma, sans-serif
; padding: 0 0.2em
; border: solid 1px #CCD3DB
; font-size: 1em
; height: 1.6em
}
input.editable
{ padding: 0.2em 0.3em
; margin-right: 0.5em
}
label
{ margin: 0 0.2em 0 0
; font-weight: bold
; font-size: 1em
; color: #405e83
}
button,
input[type="submit"]
{ border: solid 2px #41749a
; background-color: #509ed4
; color: #f9f9f9
; font-size: 0.8em
; border-radius: 3px
; cursor: pointer
; -moz-transition: all 0.3s ease
; -o-transition: all 0.3s ease
; -webkit-transition: all 0.3s ease
; transition: all 0.3s ease
}
button:hover,
input[type="submit"]:hover
{ color: #fff
; border-color: #444
}
.placeholder { color: #999 }
.hidden { display: none }
.editable,
.editable-json
{ color: #405e83
; background-color: #f6f6f6
; padding: 0.3em
}
.editable form,
.editable-json form
{ display: inline
; padding: 0
; margin: 0
}
p.error
{ text-align: center
; background-color: #fee
; padding: 0.5em 0
; width: 60%
; border: solid 2px #c99
; border-radius: 3px
}
.edit-instructions
{ font-variant: italic
; color: #888
; vertical-align: middle
}
#footer
{ width: 100%
; padding: 1em 0 2em 0
; font-size: 0.7em
; color: #28313c
; text-align: center
}
#copyright { text-align: right }

51
public/css/contact.css Normal file
View file

@ -0,0 +1,51 @@
#content
{ padding: 0
; background-color: #fff
; line-height: 1.2em
}
#about-us,
#contact-us,
#contact-email,
#address-phone
{ width: 50%
; min-height: 400px
; float: left
; margin-bottom: 0
}
#contact-email,
#address-phone
{ min-height: 300px }
#about-us, #contact-email { background-color: #e4e8ed }
#contact-us, #address-phone { background-color: #e0e4e8 }
.section
{ color: #405e83
; font-size: 0.9em
; width: 90%
; margin-top: 1em
}
table.section { margin-top: 0 }
#contact-us p.section
{ margin: 0
; padding: 0
; text-align: center
; font-size: 1em
}
#contact-spinner { display: none }
table.section { padding-top: 1em }
th
{ text-align: right
; padding-right: 0.6em
}
td a { color: #405e83 }
td a:hover { color: #000 }

View file

@ -0,0 +1,86 @@
.section { width: 90% }
#photo-form
{ height: 0
; width: 0
; opacity: 0
; position: fixed
; top: 0
}
#upload-target
{ width: 0
; height: 0
; opacity: 0
}
.save
{ width: 90%
; margin: 1em auto
; text-align: right
}
input.save-button
{ background-color: #092
; border-color: #061
}
.save-button-spinner { display: none }
table#project-info
{ width: 90%
; margin: 1em auto
}
table#project-info th
{ color: #405e83
; text-align: right
; vertical-align: top
; padding: 0.2em 0.5em 0.7em
}
input[type="text"],
input[type="tel"],
input[type="url"]
{ padding: 0 0.2em
; border: #CCD3DB 1px solid
; font-size: 1em
; height: 1.5em
; margin: 0
}
textarea
{ border: #CCD3DB 1px solid
; font-size: 0.9em
; line-height: 1.2em
; padding: 0.3em
; margin: 0
}
#photos li
{ display: inline-block
; width: 72px
; height: 84px
; padding: 0
; margin: 0.2em 0.25em 0.7em
; text-align: center
}
li#add-photo-box { display: inline-block }
#photos li#add-photo-spinner { height: 18px }
.add-photo { font-size: 0.8em }
a.add-photo { font-size: 0.9em }
.remove-photo { color: #900 }
#ie-photo-uploaderQueue { display: none }
#ie-photo-uploaderUploader
{ height: 64px
; width: 64px
}
#drag-n-drop
{ padding-left: 3em
; font-style: italic
}

2
public/css/faq.css Normal file
View file

@ -0,0 +1,2 @@
.question { font-weight: bold }

0
public/css/index.css Normal file
View file

View file

@ -0,0 +1,101 @@
/**
* jQuery lightBox plugin
* This jQuery plugin was inspired and based on Lightbox 2 by Lokesh Dhakar (http://www.huddletogether.com/projects/lightbox2/)
* and adapted to me for use like a plugin from jQuery.
* @name jquery-lightbox-0.5.css
* @author Leandro Vieira Pinho - http://leandrovieira.com
* @version 0.5
* @date April 11, 2008
* @category jQuery plugin
* @copyright (c) 2008 Leandro Vieira Pinho (leandrovieira.com)
* @license CCAttribution-ShareAlike 2.5 Brazil - http://creativecommons.org/licenses/by-sa/2.5/br/deed.en_US
* @example Visit http://leandrovieira.com/projects/jquery/lightbox/ for more informations about this jQuery plugin
*/
#jquery-overlay {
position: absolute;
top: 0;
left: 0;
z-index: 90;
width: 100%;
height: 500px;
}
#jquery-lightbox {
position: absolute;
top: 0;
left: 0;
width: 100%;
z-index: 100;
text-align: center;
line-height: 0;
}
#jquery-lightbox a img { border: none; }
#lightbox-container-image-box {
position: relative;
background-color: #fff;
width: 250px;
height: 250px;
margin: 0 auto;
}
#lightbox-container-image { padding: 10px; }
#lightbox-loading {
position: absolute;
top: 40%;
left: 0%;
height: 25%;
width: 100%;
text-align: center;
line-height: 0;
}
#lightbox-nav {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
z-index: 10;
}
#lightbox-container-image-box > #lightbox-nav { left: 0; }
#lightbox-nav a { outline: none;}
#lightbox-nav-btnPrev, #lightbox-nav-btnNext {
width: 49%;
height: 100%;
zoom: 1;
display: block;
}
#lightbox-nav-btnPrev {
left: 0;
float: left;
}
#lightbox-nav-btnNext {
right: 0;
float: right;
}
#lightbox-container-image-data-box {
font: 10px Verdana, Helvetica, sans-serif;
background-color: #fff;
margin: 0 auto;
line-height: 1.4em;
overflow: auto;
width: 100%;
padding: 0 10px 0;
}
#lightbox-container-image-data {
padding: 0 10px;
color: #666;
}
#lightbox-container-image-data #lightbox-image-details {
width: 70%;
float: left;
text-align: left;
}
#lightbox-image-details-caption { font-weight: bold; }
#lightbox-image-details-currentNumber {
display: block;
clear: left;
padding-bottom: 1.0em;
}
#lightbox-secNav-btnClose {
width: 66px;
float: right;
padding-bottom: 0.7em;
}

35
public/css/projects.css Normal file
View file

@ -0,0 +1,35 @@
table#projects
{ border-collapse: collapse
; margin: 0 auto 2em
}
#projects td { padding: 0.3em 0 }
#projects th { text-align: center }
#projects tr.headings th
{ color: #666
; padding: 0.3em 0 0.8em
}
#projects th.name,
#projects td.name
{ text-align: left }
#projects tr.project { background-color: #dce1e8 }
#projects tr.project:nth-child(even) { background-color: #e4e8ed }
#projects tr.project:hover { background-color: #eee }
#projects tr.project td
{ border-bottom: solid 1px #aaa
; padding: 0.5em 0
}
#projects tr.project.first td { border-top: solid 1px #aaa }
#projects td.created { text-align: center }
#projects td.name a
{ color: #333
; font-size: 1.2em
}

View file

@ -0,0 +1,24 @@
table#reset-password { margin: auto }
th
{ text-align: right
; vertical-align: top
; padding-top: 0.6em
; padding-right: 0.4em
}
label { line-height: 1.7em }
input[type="text"], /* fake password placeholder element created by jquery.placeholder */
input[type="email"],
input[type="password"]
{ margin: 0.3em 0 0 }
#reset-password-spinner { display: none }
#submit-cell
{ text-align: right
; color: #405e83
; padding-top: 0.5em
}

55
public/css/sign-in.css Normal file
View file

@ -0,0 +1,55 @@
#content
{ background-color: #fff
; padding-bottom: 0
}
#sign-in
{ padding: 1em
; background-color: #E4E8ED
; margin: auto
}
th
{ text-align: right
; vertical-align: top
; padding-top: 0.6em
; padding-right: 0.4em
}
label { line-height: 1.7em }
input[type="text"], /* fake password placeholder element created by jquery.placeholder */
input[type="email"],
input[type="password"]
{ margin: 0.3em 0 0 }
#password-cell { text-align: right }
#forgot-password-link
{ font-size: 0.8em
; padding-right: 0.5em
; margin-top: 0.5em
; display: inline-block
; color: #405e83
}
#forgot-password-link:hover { color: #000 }
label[for="remember"]
{ font-size: 0.9em
; font-weight: normal
}
#submit-cell
{ text-align: right
; color: #405e83
}
#sign-in-spinner { display: none }
#sign-up
{ text-align: center
; padding-top: 1em
; color: #405e83
}

85
public/css/sign-up.css Normal file
View file

@ -0,0 +1,85 @@
#blurb
{ margin: 0
; padding: 1em
; font-weight: bold
; font-size: 1.2em
; color: #509ed4
; text-align: left
}
#sign-up-table
{ width: 55%
; margin: auto 1em
; padding: 1em
; float: left
}
th
{ text-align: right
; vertical-align: top
}
td { text-align: left }
label
{ margin: 0 0.2em 0 0
; font-weight: bold
; font-size: 1em
; color: #405e83
}
input[type="text"],
input[type="email"],
input[type="password"]
{ padding: 0 0.2em
; border: #CCD3DB 1px solid
; font-size: 1em
; height: 1.6em
}
label[for="terms"]
{ font-size: 0.8em
; color: #28313c
}
#terms-cell { padding: 0.5em 0 0.7em }
#terms a { color: #28313c }
#sign-up-cell { text-align: center }
#sign-up-button
{ border: solid 1px #444
; border-radius: 3px
; background-color: #ff9806
; font-size: 1.3em
; padding: 0.2em 0.4em
; font-weight: bold
; cursor: pointer
; color: #000
}
#sign-up-button:hover
{ background-color: #FFD395
; border-color: #000
}
#sign-up-spinner { display: none }
#sign-in
{ width: 40%
; min-width: 150px
; max-width: 400px
; text-align: center
; float: right
}
#sign-in p
{ margin-top: 5em
; font-weight: bold
; color: #405e83
}
#sign-in a { text-decoration: none }
#sign-in-spinner { display: none }

2
public/css/terms.css Normal file
View file

@ -0,0 +1,2 @@
ol { list-style-type: none }

52
public/css/uploadify.css Normal file
View file

@ -0,0 +1,52 @@
/*
Uploadify v2.1.4
Release Date: November 8, 2010
Copyright (c) 2010 Ronnie Garcia, Travis Nickels
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
*/
.uploadifyQueueItem {
background-color: #F5F5F5;
border: 2px solid #E5E5E5;
font: 11px Verdana, Geneva, sans-serif;
margin-top: 5px;
padding: 10px;
width: 350px;
}
.uploadifyError {
background-color: #FDE5DD !important;
border: 2px solid #FBCBBC !important;
}
.uploadifyQueueItem .cancel {
float: right;
}
.uploadifyQueue .completed {
background-color: #E5E5E5;
}
.uploadifyProgress {
background-color: #E5E5E5;
margin-top: 10px;
width: 100%;
}
.uploadifyProgressBar {
background-color: #0099FF;
height: 3px;
width: 1px;
}

BIN
public/images/add-photo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

BIN
public/images/lightbox-blank.gif Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 700 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 812 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 832 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
public/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

BIN
public/images/spinner.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

137
public/index.html Normal file
View file

@ -0,0 +1,137 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>stormy</title>
<meta name="description" content="">
<meta name="author" content="">
<!-- Le HTML5 shim, for IE6-8 support of HTML elements -->
<!--[if lt IE 9]>
<script src="http://html5shim.googlecode.com/svn/trunk/html5.js"></script>
<![endif]-->
<!-- Le styles -->
<link href="css/bootstrap.css" rel="stylesheet">
<style>
body {
padding-top: 60px;
padding-bottom: 40px;
}
.sidebar-nav {
padding: 9px 0;
}
</style>
<link href="css/bootstrap-responsive.css" rel="stylesheet">
<!-- Le fav and touch icons -->
<link rel="shortcut icon" href="img/favicon.ico">
<link rel="apple-touch-icon" href="img/apple-touch-icon.png">
<link rel="apple-touch-icon" sizes="72x72" href="img/apple-touch-icon-72x72.png">
<link rel="apple-touch-icon" sizes="114x114" href="img/apple-touch-icon-114x114.png">
</head>
<body>
<div class="navbar navbar-fixed-top">
<div class="navbar-inner">
<div class="container-fluid">
<a class="btn btn-navbar" data-toggle="collapse" data-target=".nav-collapse">
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</a>
<a class="brand" href="#">Project name</a>
<div class="nav-collapse">
<ul class="nav">
<li class="active"><a href="#">Home</a></li>
<li><a href="#about">About</a></li>
<li><a href="#contact">Contact</a></li>
</ul>
<p class="navbar-text pull-right">Logged in as <a href="#">username</a></p>
</div><!--/.nav-collapse -->
</div>
</div>
</div>
<div class="container-fluid">
<div class="row-fluid">
<div class="span3">
<div class="well sidebar-nav">
<ul class="nav nav-list">
<li class="nav-header">Sidebar</li>
<li class="active"><a href="#">Link</a></li>
<li><a href="#">Link</a></li>
<li><a href="#">Link</a></li>
<li><a href="#">Link</a></li>
<li class="nav-header">Sidebar</li>
<li><a href="#">Link</a></li>
<li><a href="#">Link</a></li>
<li><a href="#">Link</a></li>
<li><a href="#">Link</a></li>
<li><a href="#">Link</a></li>
<li><a href="#">Link</a></li>
<li class="nav-header">Sidebar</li>
<li><a href="#">Link</a></li>
<li><a href="#">Link</a></li>
<li><a href="#">Link</a></li>
</ul>
</div><!--/.well -->
</div><!--/span-->
<div class="span9">
<div class="hero-unit">
<h1>Hello, world!</h1>
<p>This is a template for a simple marketing or informational website. It includes a large callout called the hero unit and three supporting pieces of content. Use it as a starting point to create something more unique.</p>
<p><a class="btn btn-primary btn-large">Learn more &raquo;</a></p>
</div>
<div class="row-fluid">
<div class="span4">
<h2>Heading</h2>
<p>Donec id elit non mi porta gravida at eget metus. Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit amet risus. Etiam porta sem malesuada magna mollis euismod. Donec sed odio dui. </p>
<p><a class="btn" href="#">View details &raquo;</a></p>
</div><!--/span-->
<div class="span4">
<h2>Heading</h2>
<p>Donec id elit non mi porta gravida at eget metus. Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit amet risus. Etiam porta sem malesuada magna mollis euismod. Donec sed odio dui. </p>
<p><a class="btn" href="#">View details &raquo;</a></p>
</div><!--/span-->
<div class="span4">
<h2>Heading</h2>
<p>Donec id elit non mi porta gravida at eget metus. Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit amet risus. Etiam porta sem malesuada magna mollis euismod. Donec sed odio dui. </p>
<p><a class="btn" href="#">View details &raquo;</a></p>
</div><!--/span-->
</div><!--/row-->
<div class="row-fluid">
<div class="span4">
<h2>Heading</h2>
<p>Donec id elit non mi porta gravida at eget metus. Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit amet risus. Etiam porta sem malesuada magna mollis euismod. Donec sed odio dui. </p>
<p><a class="btn" href="#">View details &raquo;</a></p>
</div><!--/span-->
<div class="span4">
<h2>Heading</h2>
<p>Donec id elit non mi porta gravida at eget metus. Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit amet risus. Etiam porta sem malesuada magna mollis euismod. Donec sed odio dui. </p>
<p><a class="btn" href="#">View details &raquo;</a></p>
</div><!--/span-->
<div class="span4">
<h2>Heading</h2>
<p>Donec id elit non mi porta gravida at eget metus. Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit amet risus. Etiam porta sem malesuada magna mollis euismod. Donec sed odio dui. </p>
<p><a class="btn" href="#">View details &raquo;</a></p>
</div><!--/span-->
</div><!--/row-->
</div><!--/span-->
</div><!--/row-->
<hr>
<footer>
<p>&copy; 2012 Sami Samhuri</p>
</footer>
</div><!--/.fluid-container-->
<!-- Le javascript
================================================== -->
<!-- Placed at the end of the document so the pages load faster -->
<script src="js/jquery.js"></script>
<script src="js/bootstrap.js"></script>
</body>
</html>

View file

@ -0,0 +1,145 @@
$(function() {
var editableOptions = {
indicator: '<img src="/images/spinner.gif"> Saving...'
, submit: 'OK'
, cancel: 'Cancel'
, tooltip: 'Click to edit'
, select: true
, onblur: 'ignore'
, placeholder: '(none)'
}
$('.editable').editable('/account/update', $.extend({}, editableOptions, {
onsubmit: function(options, el) {
var $input = $('input', el)
var value = $input.val()
if ($.trim(value)) {
$input.removeClass('error')
return true
}
else {
$input.addClass('error')
return false
}
}
}))
var updaters = {
email: updaterForField({
type: 'email address'
, name: 'email'
, validate: window.SI.EmailIsValid
, failHandlers: {
taken: function(type, name) {
$(this).after('<p id="email-taken" class="error">That email address is already taken.</p>')
}
}
})
}
$.each(['email'], function(i, id) {
var $el = $('#' + id)
$el
.data('original', $el.text())
.editable(updaters[id], $.extend({}, editableOptions, {
oncancel: function(options) {
$('#' + id + '-taken').remove()
$('#' + id + '-invalid').remove()
$('.edit-instructions').show()
}
}))
})
})
function invalidHTMLFor(type, name) {
return '<p id="' + name + '-invalid" class="error">Invalid ' + type + '.</p>'
}
// options: type, name, validate, [failHandlers]
function updaterForField(options) {
var name = options.name
, type = options.type
, validate = options.validate
, failHandlers = options.failHandlers || {}
return function(value, options) {
$('#' + name + '-taken').remove()
$('#' + name + '-invalid').remove()
var $el = $('#' + name)
, self = this
value = $.trim(value)
if (value === $.trim($el.data('original'))) {
$('.edit-instructions').show()
return value || '(none)'
}
function restoreInput(options) {
options = options || {}
self.editing = false
$(self)
.html($el.data('original'))
.trigger('click')
var input = $('input', self)
input.val(value)
if (options.error) {
input
.addClass('error')
.focus()
.select()
}
}
if (!validate(value)) {
restoreInput({ error: true })
$(this).after(invalidHTMLFor(type, name))
}
else {
$('input', this).removeClass('error')
$(this).html(options.indicator)
$.post('/account/update.json', { 'id': name, 'value': value }, function(data) {
if (data.status === 'ok') {
var previousValue = $.trim($(self).data('original'))
value = $.trim(value)
$(self)
.html(value || '(none)')
.data('original', value)
self.editing = false
$('.edit-instructions').show()
if (name === 'email') {
if (previousValue.toLowerCase() !== value.toLowerCase()) {
$('#email-verified').hide()
$('#email-verification').show()
}
}
}
else {
restoreInput({ error: true })
// custom handler
if (data.reason in failHandlers) {
failHandlers[data.reason].call(self, type, name)
}
// default, invalid
else {
$(self).after(invalidHTMLFor(type, name))
}
}
}).error(function() {
restoreInput()
alert('Failed to update ' + type + '. Try again later.')
})
}
return false
}
}

91
public/js/account.js Normal file
View file

@ -0,0 +1,91 @@
$(function() {
$('#change-password-link').click(function() {
$(this).hide()
$('#change-password').show()
$('#password-changed').hide()
$('#old-password').focus()
return false
})
$('#change-password-button').click(function() {
changePassword()
return false
})
$('#send-email-verification').click(function() {
$('#sending-email-verification').show()
$(this).hide()
var self = this
$.post('/account/send-email-verification', function(data) {
if (data.status === 'ok') {
$(self)
.after('Sent! Follow the link in your email to complete the verification.')
.remove()
}
else {
alert('Failed to send verification email. Try again later.')
}
}).error(function() {
alert('Failed to send verification email. Try again later.')
}).complete(function() {
$('#sending-email-verification').hide()
$(self).show()
})
return false
})
})
function changePassword() {
var oldPassword = $('#old-password').val()
, newPassword = $('#new-password').val()
, confirmation = $('#password-confirmation').val()
if ($.trim(oldPassword) && $.trim(newPassword) && newPassword === confirmation) {
$('#change-password-form input[type="password"]').removeClass('error')
$('#change-password-form input[type="submit"]').hide()
$('#change-password-form .spinner').show()
$.post('/account/password', $('#change-password-form').serialize(), function(data) {
if (data.status === 'ok') {
$('input[type="password"]').val('')
$('#change-password').hide()
$('#change-password-link').show()
$('#password-changed').show()
}
// incorrect old password
else if (data.reason === 'incorrect') {
$('#old-password')
.val('')
.addClass('error')
.focus()
}
// invalid new password
else {
$('#new-password')
.val('')
.addClass('error')
.focus()
$('#password-confirmation')
.val('')
.addClass('error')
}
}).error(function(x) {
alert('Failed to change password. Try again later.')
}).complete(function() {
$('#change-password-form input[type="submit"]').show()
$('#change-password-form .spinner').hide()
})
}
else {
if ($.trim(newPassword)) {
$('#password-confirmation')
.val('')
.addClass('error')
.focus()
}
else {
$('input[type="password"]').val('').addClass('error')
$('#old-password').focus()
}
}
}

View file

@ -0,0 +1,7 @@
$(function() {
$('#delete').click(function() {
return confirm("Are you sure you want to delete " + window.SI.email + "?")
})
})

View file

@ -0,0 +1,7 @@
$(function() {
$('#delete').click(function() {
return confirm("Are you sure?")
})
})

1733
public/js/bootstrap.js vendored Executable file

File diff suppressed because it is too large Load diff

22
public/js/common.js Normal file
View file

@ -0,0 +1,22 @@
$(function() {
$('#sign-out-link').click(function() {
$('#sign-out-form').submit()
return false
})
$('#flash').append('<button id="hide-flash">OK</button>')
$('#hide-flash').click(function() {
$(this).fadeOut()
$('#flash').fadeOut()
})
// global Stormy Weather object
window.SI = window.SI || {}
window.SI.EmailRegex = /^[^@]+@[^.@]+(\.[^.@]+)+$/
window.SI.EmailIsValid = function(v) { return window.SI.EmailRegex.test(v) }
$('input[placeholder], textarea[placeholder]').placeholder()
})

41
public/js/contact.js Normal file
View file

@ -0,0 +1,41 @@
$(function() {
$('#contact-form').submit(function() {
var valid = true
, focused = false
, messageField = $('#message')
, emailField = $('#email')
if ($.trim(messageField.val()) === '') {
valid = false
messageField.addClass('error')
if (!focused) {
focused = true
messageField.focus().select()
}
}
else {
messageField.removeClass('error')
}
if (!window.SI.EmailIsValid(emailField.val())) {
valid = false
emailField.addClass('error')
if (!focused) {
focused = true
emailField.focus().select()
}
}
else {
emailField.removeClass('error')
}
if (valid) {
$('input[type="submit"]').hide()
$('#contact-spinner').show()
}
return valid
})
})

215
public/js/edit-project.js Normal file
View file

@ -0,0 +1,215 @@
$(function() {
initTmpl()
$('#name').blur(function() {
var name = $.trim($(this).val())
if (name === this.placeholder) name = ''
$('.save-button').val('Save ' + (name || 'This Project'))
})
$('form#project').submit(validateProject)
initLightBox()
$('#photos').dragsort({
dragSelector: 'li.photo'
, itemSelector: 'li.photo'
, dragEnd: updatePhotoOrder
, placeHolderTemplate: '<li class="placeholder"><div></div></li>'
, itemClicked: function(item) { $('a.thumbnail', item).click() }
})
var $photos = $('#photos-container')
, $addPhotoBox = $('#add-photo-box')
, $photoUploader = $('#photo-uploader')
// fuck IE
if ($.browser.msie) {
$('#ie-photo-uploader').uploadify({
'uploader' : '/uploadify/uploadify.swf',
'script' : '/uploadify',
'multi' : false,
'buttonImg' : '/images/add-photo.png',
'method' : 'post',
'cancelImg' : '/uploadify/cancel.png',
'auto' : true,
'fileExt' : ['jpg', 'jpeg', 'png'],
'sizeLimit' : 10 * 1024 * 1024 * 1024, // 10 MB is way more than enough
'scriptData' : { id: window.SI.projectId },
'onComplete': function(_a, _b, _c, text) {
completePhotoUpload(text)
},
'onError': function() {
completePhotoUpload('fail')
},
'onOpen': function() {
$('#add-photo-spinner').remove()
$addPhotoBox.addClass('hidden').before('<li id="add-photo-spinner"><img src="/images/spinner.gif"></li>')
}
})
}
$('.add-photo').click(function() {
$photoUploader.focus().click()
return false
})
$photoUploader.change(function() {
$addPhotoBox.addClass('hidden').before('<li id="add-photo-spinner"><img src="/images/spinner.gif"></li>')
$('#photo-form').submit()
return false
})
$('#upload-target').load(function() {
completePhotoUpload($(this).contents().text())
})
var photoTemplate = window.SI.tmpl($('#photo-template').html())
function completePhotoUpload(text) {
$('#add-photo-spinner').remove()
var photoForm = $('#photo-form').get(0)
if (photoForm) photoForm.reset()
try {
var response = JSON.parse(text)
if (response.status === 'ok') {
$addPhotoBox.before(photoTemplate(response.data.photo))
initLightBox()
if (response.data.n >= 10) {
$addPhotoBox.addClass('hidden')
}
else {
$addPhotoBox.removeClass('hidden')
}
}
else {
$addPhotoBox.removeClass('hidden')
alert('Failed to add photo. Try again later.')
}
}
catch (e) {
$addPhotoBox.removeClass('hidden')
alert('Failed to add photo. Try again later.')
}
}
var removeCount = 0
$('.remove-photo').live('click', function() {
var id = this.id
, photoId = id.replace(/^remove-photo-/, '')
, data = { id: window.SI.projectId, photo_id: photoId }
, spinnerId = 'remove-photo-spinner-' + photoId
, $this = $(this)
$this.hide().after('<img id="' + spinnerId + '" src="/images/spinner.gif">')
removeCount += 1
$.post('/project/remove-photo', data, function(response) {
removeCount -= 1
if (response.status === 'ok' && removeCount === 0) {
$addPhotoBox.removeClass('hidden')
$('li.photo').remove()
$.each(response.data.photos, function(i, photo) {
$addPhotoBox.before(photoTemplate(photo))
})
initLightBox()
}
else {
if (removeCount === 0) {
$('#' + spinnerId).remove()
$this.show()
alert('Failed to remove photo. Try again later.')
}
}
}).error(function() {
removeCount -= 1
if (removeCount === 0) {
$('#' + spinnerId).remove()
$this.show()
alert('Failed to remove photo. Try again later.')
}
})
return false
})
})
function initLightBox() {
$('#photos a.thumbnail').lightBox()
$('#photos a.thumbnail').live('click', function(){ console.dir(this) })
}
// Simple JavaScript Templating
// John Resig - http://ejohn.org/ - MIT Licensed
// http://ejohn.org/blog/javascript-micro-templating/
function initTmpl() {
var cache = {}
window.SI = window.SI || {}
window.SI.tmpl = function tmpl(str, data) {
// Figure out if we're getting a template, or if we need to
// load the template - and be sure to cache the result.
var fn = !/\W/.test(str) ?
cache[str] = cache[str] ||
tmpl($('#' + str).html()) :
// Generate a reusable function that will serve as a template
// generator (and which will be cached).
new Function("obj",
"var p=[],print=function(){p.push.apply(p,arguments)};" +
// Introduce the data as local variables using with(){}
"with(obj){p.push('" +
// Convert the template into pure JavaScript
str
.replace(/[\r\t\n]/g, " ")
.split("<%").join("\t")
.replace(/((^|%>)[^\t]*)'/g, "$1\r")
.replace(/\t=(.*?)%>/g, "',$1,'")
.split("\t").join("');")
.split("%>").join("p.push('")
.split("\r").join("\\'")
+ "')}return p.join('')")
// Provide some basic currying to the user
return data ? fn( data ) : fn
}
}
function updatePhotoOrder() {
initLightBox()
var ids = []
$('#photos li.photo').each(function() {
ids.push(this.id.replace('photo-', ''))
})
var data = { id: window.SI.projectId, order: ids }
$.post('/project/photo-order', data, function(response) {
// noop
}).error(function() {
alert('Failed to reorder photos. Try again later.')
})
}
function validateProject() {
var valid = true
, nameField = $('#name')
if ($.trim(nameField.val()).length === 0) {
valid = false
nameField.addClass('error').focus().select()
}
else {
nameField.removeClass('error')
}
if (valid) {
$('.save-button').hide()
$('.save-button-spinner').show()
}
return valid
}

View file

@ -0,0 +1,10 @@
$(function() {
$('#email').focus()
$('#forgot-password-form').submit(function() {
$('input[type="submit"]', this).hide()
$('#spinner').show()
})
})

5
public/js/index.js Normal file
View file

@ -0,0 +1,5 @@
$(function() {
// this space intentionally left blank
})

View file

@ -0,0 +1,297 @@
// jQuery List DragSort v0.4
// Website: http://dragsort.codeplex.com/
// License: http://dragsort.codeplex.com/license
(function($) {
$.fn.dragsort = function(options) {
var opts = $.extend({}, $.fn.dragsort.defaults, options);
var lists = [];
var list = null, lastPos = null;
this.each(function(i, cont) {
if ($(cont).is("table") && $(cont).children().size() == 1 && $(cont).children().is("tbody"))
cont = $(cont).children().get(0);
var newList = {
draggedItem: null,
placeHolderItem: null,
pos: null,
offset: null,
offsetLimit: null,
scroll: null,
container: cont,
init: function() {
$(this.container).attr("data-listIdx", i).mousedown(this.grabItem)
$(this.container).children(opts.itemSelector).each(function(j) { $(this).attr("data-itemIdx", j); });
},
grabItem: function(e) {
if (e.which != 1 || $(e.target).is(opts.dragSelectorExclude))
return;
var elm = e.target;
while (!$(elm).is("[data-listIdx='" + $(this).attr("data-listIdx") + "'] " + opts.dragSelector)) {
if (elm == this) return;
elm = elm.parentNode;
}
if (list != null && list.draggedItem != null)
list.dropItem();
list = lists[$(this).attr("data-listIdx")];
list.draggedItem = $(elm).closest(opts.itemSelector);
var mt = parseInt(list.draggedItem.css("marginTop"));
var ml = parseInt(list.draggedItem.css("marginLeft"));
list.offset = list.draggedItem.offset();
list.offset.top = e.pageY - list.offset.top + (isNaN(mt) ? 0 : mt) - 1;
list.offset.left = e.pageX - list.offset.left + (isNaN(ml) ? 0 : ml) - 1;
list.draggedItem.startOffset = list.draggedItem.offset()
list.draggedItem.startTime = +new Date()
if (!opts.dragBetween) {
var containerHeight = $(list.container).outerHeight() == 0 ? Math.max(1, Math.round(0.5 + $(list.container).children(opts.itemSelector).size() * list.draggedItem.outerWidth() / $(list.container).outerWidth())) * list.draggedItem.outerHeight() : $(list.container).outerHeight();
list.offsetLimit = $(list.container).offset();
list.offsetLimit.right = list.offsetLimit.left + $(list.container).outerWidth() - list.draggedItem.outerWidth();
list.offsetLimit.bottom = list.offsetLimit.top + containerHeight - list.draggedItem.outerHeight();
}
var h = list.draggedItem.height();
var w = list.draggedItem.width();
var orig = list.draggedItem.attr("style");
list.draggedItem.attr("data-origStyle", orig ? orig : "");
if (opts.itemSelector == "tr") {
list.draggedItem.children().each(function() { $(this).width($(this).width()); });
list.placeHolderItem = list.draggedItem.clone().attr("data-placeHolder", true);
list.draggedItem.after(list.placeHolderItem);
list.placeHolderItem.children().each(function() { $(this).css({ borderWidth:0, width: $(this).width() + 1, height: $(this).height() + 1 }).html("&nbsp;"); });
} else {
list.draggedItem.after(opts.placeHolderTemplate);
list.placeHolderItem = list.draggedItem.next().css({ height: h, width: w }).attr("data-placeHolder", true);
}
list.draggedItem.css({ position: "absolute", opacity: 0.8, "z-index": 999, height: h, width: w });
$(lists).each(function(i, l) { l.createDropTargets(); l.buildPositionTable(); });
list.scroll = { moveX: 0, moveY: 0, maxX: $(document).width() - $(window).width(), maxY: $(document).height() - $(window).height() };
list.scroll.scrollY = window.setInterval(function() {
if (opts.scrollContainer != window) {
$(opts.scrollContainer).scrollTop($(opts.scrollContainer).scrollTop() + list.scroll.moveY);
return;
}
var t = $(opts.scrollContainer).scrollTop();
if (list.scroll.moveY > 0 && t < list.scroll.maxY || list.scroll.moveY < 0 && t > 0) {
$(opts.scrollContainer).scrollTop(t + list.scroll.moveY);
list.draggedItem.css("top", list.draggedItem.offset().top + list.scroll.moveY + 1);
}
}, 10);
list.scroll.scrollX = window.setInterval(function() {
if (opts.scrollContainer != window) {
$(opts.scrollContainer).scrollLeft($(opts.scrollContainer).scrollLeft() + list.scroll.moveX);
return;
}
var l = $(opts.scrollContainer).scrollLeft();
if (list.scroll.moveX > 0 && l < list.scroll.maxX || list.scroll.moveX < 0 && l > 0) {
$(opts.scrollContainer).scrollLeft(l + list.scroll.moveX);
list.draggedItem.css("left", list.draggedItem.offset().left + list.scroll.moveX + 1);
}
}, 10);
list.setPos(e.pageX, e.pageY);
$(document).bind("selectstart", list.stopBubble); //stop ie text selection
$(document).bind("mousemove", list.swapItems);
$(document).bind("mouseup", list.dropItem);
if (opts.scrollContainer != window)
$(window).bind("DOMMouseScroll mousewheel", list.wheel);
return false; //stop moz text selection
},
setPos: function(x, y) {
var top = y - this.offset.top;
var left = x - this.offset.left;
if (!opts.dragBetween) {
top = Math.min(this.offsetLimit.bottom, Math.max(top, this.offsetLimit.top));
left = Math.min(this.offsetLimit.right, Math.max(left, this.offsetLimit.left));
}
this.draggedItem.parents().each(function() {
if ($(this).css("position") != "static" && (!$.browser.mozilla || $(this).css("display") != "table")) {
var offset = $(this).offset();
top -= offset.top;
left -= offset.left;
return false;
}
});
if (opts.scrollContainer == window) {
y -= $(window).scrollTop();
x -= $(window).scrollLeft();
y = Math.max(0, y - $(window).height() + 5) + Math.min(0, y - 5);
x = Math.max(0, x - $(window).width() + 5) + Math.min(0, x - 5);
} else {
var cont = $(opts.scrollContainer);
var offset = cont.offset();
y = Math.max(0, y - cont.height() - offset.top) + Math.min(0, y - offset.top);
x = Math.max(0, x - cont.width() - offset.left) + Math.min(0, x - offset.left);
}
list.scroll.moveX = x == 0 ? 0 : x * opts.scrollSpeed / Math.abs(x);
list.scroll.moveY = y == 0 ? 0 : y * opts.scrollSpeed / Math.abs(y);
this.draggedItem.css({ top: top, left: left });
},
wheel: function(e) {
if (($.browser.safari || $.browser.mozilla) && list && opts.scrollContainer != window) {
var cont = $(opts.scrollContainer);
var offset = cont.offset();
if (e.pageX > offset.left && e.pageX < offset.left + cont.width() && e.pageY > offset.top && e.pageY < offset.top + cont.height()) {
var delta = e.detail ? e.detail * 5 : e.wheelDelta / -2;
cont.scrollTop(cont.scrollTop() + delta);
e.preventDefault();
}
}
},
buildPositionTable: function() {
var item = this.draggedItem == null ? null : this.draggedItem.get(0);
var pos = [];
$(this.container).children(opts.itemSelector).each(function(i, elm) {
if (elm != item) {
var loc = $(elm).offset();
loc.right = loc.left + $(elm).width();
loc.bottom = loc.top + $(elm).height();
loc.elm = elm;
pos.push(loc);
}
});
this.pos = pos;
},
dropItem: function() {
if (list.draggedItem == null)
return;
list.placeHolderItem.before(list.draggedItem);
//list.draggedItem.attr("style", "") doesn't work on IE8 and jQuery 1.5 or lower
//list.draggedItem.removeAttr("style") doesn't work on chrome and jQuery 1.6 (works jQuery 1.5 or lower)
var orig = list.draggedItem.attr("data-origStyle");
list.draggedItem.attr("style", orig);
if (orig == "")
list.draggedItem.removeAttr("style");
list.draggedItem.removeAttr("data-origStyle");
list.placeHolderItem.remove();
$("[data-dropTarget]").remove();
window.clearInterval(list.scroll.scrollY);
window.clearInterval(list.scroll.scrollX);
var changed = false;
$(lists).each(function() {
$(this.container).children(opts.itemSelector).each(function(j) {
if (parseInt($(this).attr("data-itemIdx")) != j) {
changed = true;
$(this).attr("data-itemIdx", j);
}
});
});
var duration = +new Date() - list.draggedItem.startTime
, offset = list.draggedItem.offset()
, diffY = Math.abs(list.draggedItem.startOffset.top - offset.top)
, diffX = Math.abs(list.draggedItem.startOffset.left - offset.left)
, itemClicked = duration < 500 && diffX < 3 && diffY < 3
if (changed)
opts.dragEnd.apply(list.draggedItem);
else if (itemClicked)
opts.itemClicked(list.draggedItem)
list.draggedItem = null;
$(document).unbind("selectstart", list.stopBubble);
$(document).unbind("mousemove", list.swapItems);
$(document).unbind("mouseup", list.dropItem);
if (opts.scrollContainer != window)
$(window).unbind("DOMMouseScroll mousewheel", list.wheel);
return false;
},
stopBubble: function() { return false; },
swapItems: function(e) {
if (list.draggedItem == null)
return false;
list.setPos(e.pageX, e.pageY);
var ei = list.findPos(e.pageX, e.pageY);
var nlist = list;
for (var i = 0; ei == -1 && opts.dragBetween && i < lists.length; i++) {
ei = lists[i].findPos(e.pageX, e.pageY);
nlist = lists[i];
}
if (ei == -1 || $(nlist.pos[ei].elm).attr("data-placeHolder"))
return false;
if (lastPos == null || lastPos.top > list.draggedItem.offset().top || lastPos.left > list.draggedItem.offset().left)
$(nlist.pos[ei].elm).before(list.placeHolderItem);
else
$(nlist.pos[ei].elm).after(list.placeHolderItem);
$(lists).each(function(i, l) { l.createDropTargets(); l.buildPositionTable(); });
lastPos = list.draggedItem.offset();
return false;
},
findPos: function(x, y) {
for (var i = 0; i < this.pos.length; i++) {
if (this.pos[i].left < x && this.pos[i].right > x && this.pos[i].top < y && this.pos[i].bottom > y)
return i;
}
return -1;
},
createDropTargets: function() {
if (!opts.dragBetween)
return;
$(lists).each(function() {
var ph = $(this.container).find("[data-placeHolder]");
var dt = $(this.container).find("[data-dropTarget]");
if (ph.size() > 0 && dt.size() > 0)
dt.remove();
else if (ph.size() == 0 && dt.size() == 0) {
//list.placeHolderItem.clone().removeAttr("data-placeHolder") crashes in IE7 and jquery 1.5.1 (doesn't in jquery 1.4.2 or IE8)
$(this.container).append(list.placeHolderItem.removeAttr("data-placeHolder").clone().attr("data-dropTarget", true));
list.placeHolderItem.attr("data-placeHolder", true);
}
});
}
};
newList.init();
lists.push(newList);
});
return this;
};
$.fn.dragsort.defaults = {
itemClicked: function() { },
itemSelector: "li",
dragSelector: "li",
dragSelectorExclude: "input, textarea, a[href]",
dragEnd: function() { },
dragBetween: false,
placeHolderTemplate: "<li>&nbsp;</li>",
scrollContainer: window,
scrollSpeed: 5
};
})(jQuery);

View file

@ -0,0 +1,548 @@
/*
* Jeditable - jQuery in place edit plugin
*
* Copyright (c) 2006-2009 Mika Tuupola, Dylan Verheul
*
* Licensed under the MIT license:
* http://www.opensource.org/licenses/mit-license.php
*
* Project home:
* http://www.appelsiini.net/projects/jeditable
*
* Based on editable by Dylan Verheul <dylan_at_dyve.net>:
* http://www.dyve.net/jquery/?editable
*
*/
/**
* Version 1.7.1
*
* ** means there is basic unit tests for this parameter.
*
* @name Jeditable
* @type jQuery
* @param String target (POST) URL or function to send edited content to **
* @param Hash options additional options
* @param String options[method] method to use to send edited content (POST or PUT) **
* @param Function options[callback] Function to run after submitting edited content **
* @param String options[name] POST parameter name of edited content
* @param String options[id] POST parameter name of edited div id
* @param Hash options[submitdata] Extra parameters to send when submitting edited content.
* @param String options[type] text, textarea or select (or any 3rd party input type) **
* @param Integer options[rows] number of rows if using textarea **
* @param Integer options[cols] number of columns if using textarea **
* @param Mixed options[height] 'auto', 'none' or height in pixels **
* @param Mixed options[width] 'auto', 'none' or width in pixels **
* @param String options[loadurl] URL to fetch input content before editing **
* @param String options[loadtype] Request type for load url. Should be GET or POST.
* @param String options[loadtext] Text to display while loading external content.
* @param Mixed options[loaddata] Extra parameters to pass when fetching content before editing.
* @param Mixed options[data] Or content given as paramameter. String or function.**
* @param String options[indicator] indicator html to show when saving
* @param String options[tooltip] optional tooltip text via title attribute **
* @param String options[event] jQuery event such as 'click' of 'dblclick' **
* @param String options[submit] submit button value, empty means no button **
* @param String options[cancel] cancel button value, empty means no button **
* @param String options[cssclass] CSS class to apply to input form. 'inherit' to copy from parent. **
* @param String options[style] Style to apply to input form 'inherit' to copy from parent. **
* @param String options[select] true or false, when true text is highlighted ??
* @param String options[placeholder] Placeholder text or html to insert when element is empty. **
* @param String options[onblur] 'cancel', 'submit', 'ignore' or function ??
*
* @param Function options[onsubmit] function(settings, original) { ... } called before submit
* @param Function options[onreset] function(settings, original) { ... } called before reset
* @param Function options[onerror] function(settings, original, xhr) { ... } called on error
*
* @param Hash options[ajaxoptions] jQuery Ajax options. See docs.jquery.com.
*
*/
(function($) {
$.fn.editable = function(target, options) {
if ('disable' == target) {
$(this).data('disabled.editable', true);
return;
}
if ('enable' == target) {
$(this).data('disabled.editable', false);
return;
}
if ('destroy' == target) {
$(this)
.unbind($(this).data('event.editable'))
.removeData('disabled.editable')
.removeData('event.editable');
return;
}
var settings = $.extend({}, $.fn.editable.defaults, {target:target}, options);
/* setup some functions */
var plugin = $.editable.types[settings.type].plugin || function() { };
var submit = $.editable.types[settings.type].submit || function() { };
var buttons = $.editable.types[settings.type].buttons
|| $.editable.types['defaults'].buttons;
var content = $.editable.types[settings.type].content
|| $.editable.types['defaults'].content;
var element = $.editable.types[settings.type].element
|| $.editable.types['defaults'].element;
var reset = $.editable.types[settings.type].reset
|| $.editable.types['defaults'].reset;
var callback = settings.callback || function() { };
var onedit = settings.onedit || function() { };
var onsubmit = settings.onsubmit || function() { };
var onreset = settings.onreset || function() { };
var onerror = settings.onerror || reset;
/* show tooltip */
if (settings.tooltip) {
$(this).attr('title', settings.tooltip);
}
settings.autowidth = 'auto' == settings.width;
settings.autoheight = 'auto' == settings.height;
return this.each(function() {
/* save this to self because this changes when scope changes */
var self = this;
/* inlined block elements lose their width and height after first edit */
/* save them for later use as workaround */
var savedwidth = $(self).width();
var savedheight = $(self).height();
/* save so it can be later used by $.editable('destroy') */
$(this).data('event.editable', settings.event);
/* if element is empty add something clickable (if requested) */
if (!$.trim($(this).html())) {
$(this).html(settings.placeholder);
}
$(this).bind(settings.event, function(e) {
/* abort if disabled for this element */
if (true === $(this).data('disabled.editable')) {
return;
}
/* prevent throwing an exeption if edit field is clicked again */
if (self.editing) {
return;
}
/* abort if onedit hook returns false */
if (false === onedit.apply(this, [settings, self])) {
return;
}
/* prevent default action and bubbling */
e.preventDefault();
e.stopPropagation();
/* remove tooltip */
if (settings.tooltip) {
$(self).removeAttr('title');
}
/* figure out how wide and tall we are, saved width and height */
/* are workaround for http://dev.jquery.com/ticket/2190 */
if (0 == $(self).width()) {
//$(self).css('visibility', 'hidden');
settings.width = savedwidth;
settings.height = savedheight;
} else {
if (settings.width != 'none') {
settings.width =
settings.autowidth ? $(self).width() : settings.width;
}
if (settings.height != 'none') {
settings.height =
settings.autoheight ? $(self).height() : settings.height;
}
}
//$(this).css('visibility', '');
/* remove placeholder text, replace is here because of IE */
if ($(this).html().toLowerCase().replace(/(;|")/g, '') ==
settings.placeholder.toLowerCase().replace(/(;|")/g, '')) {
$(this).html('');
}
self.editing = true;
self.revert = $(self).html();
$(self).html('');
/* create the form object */
var form = $('<form />');
/* apply css or style or both */
if (settings.cssclass) {
if ('inherit' == settings.cssclass) {
form.attr('class', $(self).attr('class'));
} else {
form.attr('class', settings.cssclass);
}
}
if (settings.style) {
if ('inherit' == settings.style) {
form.attr('style', $(self).attr('style'));
/* IE needs the second line or display wont be inherited */
form.css('display', $(self).css('display'));
} else {
form.attr('style', settings.style);
}
}
/* add main input element to form and store it in input */
var input = element.apply(form, [settings, self]);
/* set input content via POST, GET, given data or existing value */
var input_content;
if (settings.loadurl) {
var t = setTimeout(function() {
input.disabled = true;
content.apply(form, [settings.loadtext, settings, self]);
}, 100);
var loaddata = {};
loaddata[settings.id] = self.id;
if ($.isFunction(settings.loaddata)) {
$.extend(loaddata, settings.loaddata.apply(self, [self.revert, settings]));
} else {
$.extend(loaddata, settings.loaddata);
}
$.ajax({
type : settings.loadtype,
url : settings.loadurl,
data : loaddata,
async : false,
success: function(result) {
window.clearTimeout(t);
input_content = result;
input.disabled = false;
}
});
} else if (settings.data) {
input_content = settings.data;
if ($.isFunction(settings.data)) {
input_content = settings.data.apply(self, [self.revert, settings]);
}
} else {
input_content = self.revert;
}
content.apply(form, [input_content, settings, self]);
input.attr('name', settings.name);
/* add buttons to the form */
buttons.apply(form, [settings, self]);
/* add created form to self */
$(self).append(form);
/* attach 3rd party plugin if requested */
plugin.apply(form, [settings, self]);
/* focus to first visible form element */
$(':input:visible:enabled:first', form).focus();
/* highlight input contents when requested */
if (settings.select) {
input.select();
}
/* discard changes if pressing esc */
input.keydown(function(e) {
if (e.keyCode == 27) {
e.preventDefault();
//self.reset();
reset.apply(form, [settings, self]);
}
});
/* discard, submit or nothing with changes when clicking outside */
/* do nothing is usable when navigating with tab */
var t;
if ('cancel' == settings.onblur) {
input.blur(function(e) {
/* prevent canceling if submit was clicked */
t = setTimeout(function() {
reset.apply(form, [settings, self]);
}, 500);
});
} else if ('submit' == settings.onblur) {
input.blur(function(e) {
/* prevent double submit if submit was clicked */
t = setTimeout(function() {
form.submit();
}, 200);
});
} else if ($.isFunction(settings.onblur)) {
input.blur(function(e) {
settings.onblur.apply(self, [input.val(), settings]);
});
} else {
input.blur(function(e) {
/* TODO: maybe something here */
});
}
form.submit(function(e) {
if (t) {
clearTimeout(t);
}
/* do no submit */
e.preventDefault();
/* call before submit hook. */
/* if it returns false abort submitting */
if (false !== onsubmit.apply(form, [settings, self])) {
/* custom inputs call before submit hook. */
/* if it returns false abort submitting */
if (false !== submit.apply(form, [settings, self])) {
/* check if given target is function */
if ($.isFunction(settings.target)) {
var str = settings.target.apply(self, [input.val(), settings]);
if (str !== false) {
$(self).html(str);
self.editing = false;
callback.apply(self, [self.innerHTML, settings]);
/* TODO: this is not dry */
if (!$.trim($(self).html())) {
$(self).html(settings.placeholder);
}
}
} else {
/* add edited content and id of edited element to POST */
var submitdata = {};
submitdata[settings.name] = input.val();
submitdata[settings.id] = self.id;
/* add extra data to be POST:ed */
if ($.isFunction(settings.submitdata)) {
$.extend(submitdata, settings.submitdata.apply(self, [self.revert, settings]));
} else {
$.extend(submitdata, settings.submitdata);
}
/* quick and dirty PUT support */
if ('PUT' == settings.method) {
submitdata['_method'] = 'put';
}
/* show the saving indicator */
$(self).html(settings.indicator);
/* defaults for ajaxoptions */
var ajaxoptions = {
type : 'POST',
data : submitdata,
dataType: 'html',
url : settings.target,
success : function(result, status) {
if (ajaxoptions.dataType == 'html') {
$(self).html(result);
}
self.editing = false;
callback.apply(self, [result, settings]);
if (!$.trim($(self).html())) {
$(self).html(settings.placeholder);
}
},
error : function(xhr, status, error) {
onerror.apply(form, [settings, self, xhr]);
}
};
/* override with what is given in settings.ajaxoptions */
$.extend(ajaxoptions, settings.ajaxoptions);
$.ajax(ajaxoptions);
}
}
}
/* show tooltip again */
$(self).attr('title', settings.tooltip);
return false;
});
});
/* privileged methods */
this.reset = function(form) {
/* prevent calling reset twice when blurring */
if (this.editing) {
/* before reset hook, if it returns false abort reseting */
if (false !== onreset.apply(form, [settings, self])) {
$(self).html(self.revert);
self.editing = false;
if (!$.trim($(self).html())) {
$(self).html(settings.placeholder);
}
/* show tooltip again */
if (settings.tooltip) {
$(self).attr('title', settings.tooltip);
}
}
}
};
});
};
$.editable = {
types: {
defaults: {
element : function(settings, original) {
var input = $('<input type="hidden"></input>');
$(this).append(input);
return(input);
},
content : function(string, settings, original) {
$(':input:first', this).val(string);
},
reset : function(settings, original) {
original.reset(this);
},
buttons : function(settings, original) {
var form = this;
if (settings.submit) {
/* if given html string use that */
if (settings.submit.match(/>$/)) {
var submit = $(settings.submit).click(function() {
if (submit.attr("type") != "submit") {
form.submit();
}
});
/* otherwise use button with given string as text */
} else {
var submit = $('<button type="submit" />');
submit.html(settings.submit);
}
$(this).append(submit);
}
if (settings.cancel) {
/* if given html string use that */
if (settings.cancel.match(/>$/)) {
var cancel = $(settings.cancel);
/* otherwise use button with given string as text */
} else {
var cancel = $('<button type="cancel" />');
cancel.html(settings.cancel);
}
$(this).append(cancel);
$(cancel).click(function(event) {
//original.reset();
if ($.isFunction($.editable.types[settings.type].reset)) {
var reset = $.editable.types[settings.type].reset;
} else {
var reset = $.editable.types['defaults'].reset;
}
reset.apply(form, [settings, original]);
if ($.isFunction(settings.oncancel)) {
settings.oncancel.apply(form, [settings, original]);
}
return false;
});
}
}
},
text: {
element : function(settings, original) {
var input = $('<input type="text" class="editable" />');
if (settings.width != 'none') { input.width(settings.width); }
if (settings.height != 'none') { input.height(settings.height); }
/* https://bugzilla.mozilla.org/show_bug.cgi?id=236791 */
//input[0].setAttribute('autocomplete','off');
input.attr('autocomplete','off');
$(this).append(input);
return(input);
}
},
textarea: {
element : function(settings, original) {
var textarea = $('<textarea />');
if (settings.rows) {
textarea.attr('rows', settings.rows);
} else if (settings.height != "none") {
textarea.height(settings.height);
}
if (settings.cols) {
textarea.attr('cols', settings.cols);
} else if (settings.width != "none") {
textarea.width(settings.width);
}
$(this).append(textarea);
return(textarea);
}
},
select: {
element : function(settings, original) {
var select = $('<select />');
$(this).append(select);
return(select);
},
content : function(data, settings, original) {
/* If it is string assume it is json. */
if (String == data.constructor) {
eval ('var json = ' + data);
} else {
/* Otherwise assume it is a hash already. */
var json = data;
}
for (var key in json) {
if (!json.hasOwnProject(key)) {
continue;
}
if ('selected' == key) {
continue;
}
var option = $('<option />').val(key).append(json[key]);
$('select', this).append(option);
}
/* Loop option again to set selected. IE needed this... */
$('select', this).children().each(function() {
if ($(this).val() == json['selected'] ||
$(this).text() == $.trim(original.revert)) {
$(this).attr('selected', 'selected');
}
});
}
}
},
/* Add new input type */
addInputType: function(name, input) {
$.editable.types[name] = input;
}
};
// publicly accessible defaults
$.fn.editable.defaults = {
name : 'value',
id : 'id',
type : 'text',
width : 'auto',
height : 'auto',
event : 'click.editable',
onblur : 'cancel',
loadtype : 'GET',
loadtext : 'Loading...',
placeholder: 'Click to edit',
loaddata : {},
submitdata : {},
ajaxoptions: {}
};
})(jQuery);

View file

@ -0,0 +1,472 @@
/**
* jQuery lightBox plugin
* This jQuery plugin was inspired and based on Lightbox 2 by Lokesh Dhakar (http://www.huddletogether.com/projects/lightbox2/)
* and adapted to me for use like a plugin from jQuery.
* @name jquery-lightbox-0.5.js
* @author Leandro Vieira Pinho - http://leandrovieira.com
* @version 0.5
* @date April 11, 2008
* @category jQuery plugin
* @copyright (c) 2008 Leandro Vieira Pinho (leandrovieira.com)
* @license CCAttribution-ShareAlike 2.5 Brazil - http://creativecommons.org/licenses/by-sa/2.5/br/deed.en_US
* @example Visit http://leandrovieira.com/projects/jquery/lightbox/ for more informations about this jQuery plugin
*/
// Offering a Custom Alias suport - More info: http://docs.jquery.com/Plugins/Authoring#Custom_Alias
(function($) {
/**
* $ is an alias to jQuery object
*
*/
$.fn.lightBox = function(settings) {
// Settings to configure the jQuery lightBox plugin how you like
settings = jQuery.extend({
// Configuration related to overlay
overlayBgColor: '#000', // (string) Background color to overlay; inform a hexadecimal value like: #RRGGBB. Where RR, GG, and BB are the hexadecimal values for the red, green, and blue values of the color.
overlayOpacity: 0.8, // (integer) Opacity value to overlay; inform: 0.X. Where X are number from 0 to 9
// Configuration related to navigation
fixedNavigation: false, // (boolean) Boolean that informs if the navigation (next and prev button) will be fixed or not in the interface.
// Configuration related to images
imageLoading: '/images/lightbox-ico-loading.gif', // (string) Path and the name of the loading icon
imageBtnPrev: '/images/lightbox-btn-prev.gif', // (string) Path and the name of the prev button image
imageBtnNext: '/images/lightbox-btn-next.gif', // (string) Path and the name of the next button image
imageBtnClose: '/images/lightbox-btn-close.gif', // (string) Path and the name of the close btn
imageBlank: '/images/lightbox-blank.gif', // (string) Path and the name of a blank image (one pixel)
// Configuration related to container image box
containerBorderSize: 10, // (integer) If you adjust the padding in the CSS for the container, #lightbox-container-image-box, you will need to update this value
containerResizeSpeed: 400, // (integer) Specify the resize duration of container image. These number are miliseconds. 400 is default.
// Configuration related to texts in caption. For example: Image 2 of 8. You can alter either "Image" and "of" texts.
txtImage: 'Image', // (string) Specify text "Image"
txtOf: 'of', // (string) Specify text "of"
// Configuration related to keyboard navigation
keyToClose: 'c', // (string) (c = close) Letter to close the jQuery lightBox interface. Beyond this letter, the letter X and the SCAPE key is used to.
keyToPrev: 'p', // (string) (p = previous) Letter to show the previous image
keyToNext: 'n', // (string) (n = next) Letter to show the next image.
// Don´t alter these variables in any way
imageArray: [],
activeImage: 0
},settings);
// Caching the jQuery object with all elements matched
var jQueryMatchedObj = this; // This, in this context, refer to jQuery object
/**
* Initializing the plugin calling the start function
*
* @return boolean false
*/
function _initialize() {
_start(this,jQueryMatchedObj); // This, in this context, refer to object (link) which the user have clicked
return false; // Avoid the browser following the link
}
/**
* Start the jQuery lightBox plugin
*
* @param object objClicked The object (link) whick the user have clicked
* @param object jQueryMatchedObj The jQuery object with all elements matched
*/
function _start(objClicked,jQueryMatchedObj) {
// Hime some elements to avoid conflict with overlay in IE. These elements appear above the overlay.
$('embed, object, select').css({ 'visibility' : 'hidden' });
// Call the function to create the markup structure; style some elements; assign events in some elements.
_set_interface();
// Unset total images in imageArray
settings.imageArray.length = 0;
// Unset image active information
settings.activeImage = 0;
// We have an image set? Or just an image? Let´s see it.
if ( jQueryMatchedObj.length == 1 ) {
settings.imageArray.push(new Array(objClicked.getAttribute('href'),objClicked.getAttribute('title')));
} else {
// Add an Array (as many as we have), with href and title atributes, inside the Array that storage the images references
for ( var i = 0; i < jQueryMatchedObj.length; i++ ) {
settings.imageArray.push(new Array(jQueryMatchedObj[i].getAttribute('href'),jQueryMatchedObj[i].getAttribute('title')));
}
}
while ( settings.imageArray[settings.activeImage][0] != objClicked.getAttribute('href') ) {
settings.activeImage++;
}
// Call the function that prepares image exibition
_set_image_to_view();
}
/**
* Create the jQuery lightBox plugin interface
*
* The HTML markup will be like that:
<div id="jquery-overlay"></div>
<div id="jquery-lightbox">
<div id="lightbox-container-image-box">
<div id="lightbox-container-image">
<img src="../fotos/XX.jpg" id="lightbox-image">
<div id="lightbox-nav">
<a href="#" id="lightbox-nav-btnPrev"></a>
<a href="#" id="lightbox-nav-btnNext"></a>
</div>
<div id="lightbox-loading">
<a href="#" id="lightbox-loading-link">
<img src="/images/lightbox-ico-loading.gif">
</a>
</div>
</div>
</div>
<div id="lightbox-container-image-data-box">
<div id="lightbox-container-image-data">
<div id="lightbox-image-details">
<span id="lightbox-image-details-caption"></span>
<span id="lightbox-image-details-currentNumber"></span>
</div>
<div id="lightbox-secNav">
<a href="#" id="lightbox-secNav-btnClose">
<img src="/images/lightbox-btn-close.gif">
</a>
</div>
</div>
</div>
</div>
*
*/
function _set_interface() {
// Apply the HTML markup into body tag
$('body').append('<div id="jquery-overlay"></div><div id="jquery-lightbox"><div id="lightbox-container-image-box"><div id="lightbox-container-image"><img id="lightbox-image"><div style="" id="lightbox-nav"><a href="#" id="lightbox-nav-btnPrev"></a><a href="#" id="lightbox-nav-btnNext"></a></div><div id="lightbox-loading"><a href="#" id="lightbox-loading-link"><img src="' + settings.imageLoading + '"></a></div></div></div><div id="lightbox-container-image-data-box"><div id="lightbox-container-image-data"><div id="lightbox-image-details"><span id="lightbox-image-details-caption"></span><span id="lightbox-image-details-currentNumber"></span></div><div id="lightbox-secNav"><a href="#" id="lightbox-secNav-btnClose"><img src="' + settings.imageBtnClose + '"></a></div></div></div></div>');
// Get page sizes
var arrPageSizes = ___getPageSize();
// Style overlay and show it
$('#jquery-overlay').css({
backgroundColor: settings.overlayBgColor,
opacity: settings.overlayOpacity,
width: arrPageSizes[0],
height: arrPageSizes[1]
}).fadeIn();
// Get page scroll
var arrPageScroll = ___getPageScroll();
// Calculate top and left offset for the jquery-lightbox div object and show it
$('#jquery-lightbox').css({
top: arrPageScroll[1] + (arrPageSizes[3] / 10),
left: arrPageScroll[0]
}).show();
// Assigning click events in elements to close overlay
$('#jquery-overlay,#jquery-lightbox').click(function() {
_finish();
});
// Assign the _finish function to lightbox-loading-link and lightbox-secNav-btnClose objects
$('#lightbox-loading-link,#lightbox-secNav-btnClose').click(function() {
_finish();
return false;
});
// If window was resized, calculate the new overlay dimensions
$(window).resize(function() {
// Get page sizes
var arrPageSizes = ___getPageSize();
// Style overlay and show it
$('#jquery-overlay').css({
width: arrPageSizes[0],
height: arrPageSizes[1]
});
// Get page scroll
var arrPageScroll = ___getPageScroll();
// Calculate top and left offset for the jquery-lightbox div object and show it
$('#jquery-lightbox').css({
top: arrPageScroll[1] + (arrPageSizes[3] / 10),
left: arrPageScroll[0]
});
});
}
/**
* Prepares image exibition; doing a image´s preloader to calculate it´s size
*
*/
function _set_image_to_view() { // show the loading
// Show the loading
$('#lightbox-loading').show();
if ( settings.fixedNavigation ) {
$('#lightbox-image,#lightbox-container-image-data-box,#lightbox-image-details-currentNumber').hide();
} else {
// Hide some elements
$('#lightbox-image,#lightbox-nav,#lightbox-nav-btnPrev,#lightbox-nav-btnNext,#lightbox-container-image-data-box,#lightbox-image-details-currentNumber').hide();
}
// Image preload process
var objImagePreloader = new Image();
objImagePreloader.onload = function() {
$('#lightbox-image').attr('src',settings.imageArray[settings.activeImage][0]);
// Perfomance an effect in the image container resizing it
_resize_container_image_box(objImagePreloader.width,objImagePreloader.height);
// clear onLoad, IE behaves irratically with animated gifs otherwise
objImagePreloader.onload=function(){};
};
objImagePreloader.src = settings.imageArray[settings.activeImage][0];
};
/**
* Perfomance an effect in the image container resizing it
*
* @param integer intImageWidth The image´s width that will be showed
* @param integer intImageHeight The image´s height that will be showed
*/
function _resize_container_image_box(intImageWidth,intImageHeight) {
// Get current width and height
var intCurrentWidth = $('#lightbox-container-image-box').width();
var intCurrentHeight = $('#lightbox-container-image-box').height();
// Get the width and height of the selected image plus the padding
var intWidth = (intImageWidth + (settings.containerBorderSize * 2)); // Plus the image´s width and the left and right padding value
var intHeight = (intImageHeight + (settings.containerBorderSize * 2)); // Plus the image´s height and the left and right padding value
// Diferences
var intDiffW = intCurrentWidth - intWidth;
var intDiffH = intCurrentHeight - intHeight;
// Perfomance the effect
$('#lightbox-container-image-box').animate({ width: intWidth, height: intHeight },settings.containerResizeSpeed,function() { _show_image(); });
if ( ( intDiffW == 0 ) && ( intDiffH == 0 ) ) {
if ( $.browser.msie ) {
___pause(250);
} else {
___pause(100);
}
}
$('#lightbox-container-image-data-box').css({ width: intImageWidth });
$('#lightbox-nav-btnPrev,#lightbox-nav-btnNext').css({ height: intImageHeight + (settings.containerBorderSize * 2) });
};
/**
* Show the prepared image
*
*/
function _show_image() {
$('#lightbox-loading').hide();
$('#lightbox-image').fadeIn(function() {
_show_image_data();
_set_navigation();
});
_preload_neighbor_images();
};
/**
* Show the image information
*
*/
function _show_image_data() {
$('#lightbox-container-image-data-box').slideDown('fast');
$('#lightbox-image-details-caption').hide();
if ( settings.imageArray[settings.activeImage][1] ) {
$('#lightbox-image-details-caption').html(settings.imageArray[settings.activeImage][1]).show();
}
// If we have a image set, display 'Image X of X'
if ( settings.imageArray.length > 1 ) {
$('#lightbox-image-details-currentNumber').html(settings.txtImage + ' ' + ( settings.activeImage + 1 ) + ' ' + settings.txtOf + ' ' + settings.imageArray.length).show();
}
}
/**
* Display the button navigations
*
*/
function _set_navigation() {
$('#lightbox-nav').show();
// Instead to define this configuration in CSS file, we define here. And it´s need to IE. Just.
$('#lightbox-nav-btnPrev,#lightbox-nav-btnNext').css({ 'background' : 'transparent url(' + settings.imageBlank + ') no-repeat' });
// Show the prev button, if not the first image in set
if ( settings.activeImage != 0 ) {
if ( settings.fixedNavigation ) {
$('#lightbox-nav-btnPrev').css({ 'background' : 'url(' + settings.imageBtnPrev + ') left 15% no-repeat' })
.unbind()
.bind('click',function() {
settings.activeImage = settings.activeImage - 1;
_set_image_to_view();
return false;
});
} else {
// Show the images button for Next buttons
$('#lightbox-nav-btnPrev').unbind().hover(function() {
$(this).css({ 'background' : 'url(' + settings.imageBtnPrev + ') left 15% no-repeat' });
},function() {
$(this).css({ 'background' : 'transparent url(' + settings.imageBlank + ') no-repeat' });
}).show().bind('click',function() {
settings.activeImage = settings.activeImage - 1;
_set_image_to_view();
return false;
});
}
}
// Show the next button, if not the last image in set
if ( settings.activeImage != ( settings.imageArray.length -1 ) ) {
if ( settings.fixedNavigation ) {
$('#lightbox-nav-btnNext').css({ 'background' : 'url(' + settings.imageBtnNext + ') right 15% no-repeat' })
.unbind()
.bind('click',function() {
settings.activeImage = settings.activeImage + 1;
_set_image_to_view();
return false;
});
} else {
// Show the images button for Next buttons
$('#lightbox-nav-btnNext').unbind().hover(function() {
$(this).css({ 'background' : 'url(' + settings.imageBtnNext + ') right 15% no-repeat' });
},function() {
$(this).css({ 'background' : 'transparent url(' + settings.imageBlank + ') no-repeat' });
}).show().bind('click',function() {
settings.activeImage = settings.activeImage + 1;
_set_image_to_view();
return false;
});
}
}
// Enable keyboard navigation
_enable_keyboard_navigation();
}
/**
* Enable a support to keyboard navigation
*
*/
function _enable_keyboard_navigation() {
$(document).keydown(function(objEvent) {
_keyboard_action(objEvent);
});
}
/**
* Disable the support to keyboard navigation
*
*/
function _disable_keyboard_navigation() {
$(document).unbind();
}
/**
* Perform the keyboard actions
*
*/
function _keyboard_action(objEvent) {
// To ie
if ( objEvent == null ) {
keycode = event.keyCode;
escapeKey = 27;
// To Mozilla
} else {
keycode = objEvent.keyCode;
escapeKey = objEvent.DOM_VK_ESCAPE;
}
// Get the key in lower case form
key = String.fromCharCode(keycode).toLowerCase();
// Verify the keys to close the ligthBox
if ( ( key == settings.keyToClose ) || ( key == 'x' ) || ( keycode == escapeKey ) ) {
_finish();
}
// Verify the key to show the previous image
if ( ( key == settings.keyToPrev ) || ( keycode == 37 ) ) {
// If we´re not showing the first image, call the previous
if ( settings.activeImage != 0 ) {
settings.activeImage = settings.activeImage - 1;
_set_image_to_view();
_disable_keyboard_navigation();
}
}
// Verify the key to show the next image
if ( ( key == settings.keyToNext ) || ( keycode == 39 ) ) {
// If we´re not showing the last image, call the next
if ( settings.activeImage != ( settings.imageArray.length - 1 ) ) {
settings.activeImage = settings.activeImage + 1;
_set_image_to_view();
_disable_keyboard_navigation();
}
}
}
/**
* Preload prev and next images being showed
*
*/
function _preload_neighbor_images() {
if ( (settings.imageArray.length -1) > settings.activeImage ) {
objNext = new Image();
objNext.src = settings.imageArray[settings.activeImage + 1][0];
}
if ( settings.activeImage > 0 ) {
objPrev = new Image();
objPrev.src = settings.imageArray[settings.activeImage -1][0];
}
}
/**
* Remove jQuery lightBox plugin HTML markup
*
*/
function _finish() {
$('#jquery-lightbox').remove();
$('#jquery-overlay').fadeOut(function() { $('#jquery-overlay').remove(); });
// Show some elements to avoid conflict with overlay in IE. These elements appear above the overlay.
$('embed, object, select').css({ 'visibility' : 'visible' });
}
/**
/ THIRD FUNCTION
* getPageSize() by quirksmode.com
*
* @return Array Return an array with page width, height and window width, height
*/
function ___getPageSize() {
var xScroll, yScroll;
if (window.innerHeight && window.scrollMaxY) {
xScroll = window.innerWidth + window.scrollMaxX;
yScroll = window.innerHeight + window.scrollMaxY;
} else if (document.body.scrollHeight > document.body.offsetHeight){ // all but Explorer Mac
xScroll = document.body.scrollWidth;
yScroll = document.body.scrollHeight;
} else { // Explorer Mac...would also work in Explorer 6 Strict, Mozilla and Safari
xScroll = document.body.offsetWidth;
yScroll = document.body.offsetHeight;
}
var windowWidth, windowHeight;
if (self.innerHeight) { // all except Explorer
if(document.documentElement.clientWidth){
windowWidth = document.documentElement.clientWidth;
} else {
windowWidth = self.innerWidth;
}
windowHeight = self.innerHeight;
} else if (document.documentElement && document.documentElement.clientHeight) { // Explorer 6 Strict Mode
windowWidth = document.documentElement.clientWidth;
windowHeight = document.documentElement.clientHeight;
} else if (document.body) { // other Explorers
windowWidth = document.body.clientWidth;
windowHeight = document.body.clientHeight;
}
// for small pages with total height less then height of the viewport
if(yScroll < windowHeight){
pageHeight = windowHeight;
} else {
pageHeight = yScroll;
}
// for small pages with total width less then width of the viewport
if(xScroll < windowWidth){
pageWidth = xScroll;
} else {
pageWidth = windowWidth;
}
arrayPageSize = new Array(pageWidth,pageHeight,windowWidth,windowHeight);
return arrayPageSize;
};
/**
/ THIRD FUNCTION
* getPageScroll() by quirksmode.com
*
* @return Array Return an array with x,y page scroll values.
*/
function ___getPageScroll() {
var xScroll, yScroll;
if (self.pageYOffset) {
yScroll = self.pageYOffset;
xScroll = self.pageXOffset;
} else if (document.documentElement && document.documentElement.scrollTop) { // Explorer 6 Strict
yScroll = document.documentElement.scrollTop;
xScroll = document.documentElement.scrollLeft;
} else if (document.body) {// all other Explorers
yScroll = document.body.scrollTop;
xScroll = document.body.scrollLeft;
}
arrayPageScroll = new Array(xScroll,yScroll);
return arrayPageScroll;
};
/**
* Stop the code execution from a escified time in milisecond
*
*/
function ___pause(ms) {
var date = new Date();
curDate = null;
do { var curDate = new Date(); }
while ( curDate - date < ms);
};
// Return the jQuery object for chaining. The unbind method is used to avoid click conflict when the plugin is called more than once
return this.unbind('click').click(_initialize);
};
})(jQuery); // Call and execute the function immediately passing the jQuery object

View file

@ -0,0 +1,106 @@
/*
* Placeholder plugin for jQuery
* ---
* Copyright 2010, Daniel Stocks (http://webcloud.se)
* Released under the MIT, BSD, and GPL Licenses.
*/
(function($) {
function Placeholder(input) {
this.input = input;
if (input.attr('type') == 'password') {
this.handlePassword();
}
// Prevent placeholder values from submitting
$(input[0].form).submit(function() {
if (input.hasClass('placeholder') && input[0].value == input.attr('placeholder')) {
input[0].value = '';
}
});
}
Placeholder.prototype = {
show : function(loading) {
// FF and IE saves values when you refresh the page. If the user refreshes the page with
// the placeholders showing they will be the default values and the input fields won't be empty.
if (this.input[0].value === '' || (loading && this.valueIsPlaceholder())) {
if (this.isPassword) {
try {
this.input[0].setAttribute('type', 'text');
} catch (e) {
this.input.before(this.fakePassword.show()).hide();
}
}
this.input.addClass('placeholder');
this.input[0].value = this.input.attr('placeholder');
}
},
hide : function() {
if (this.valueIsPlaceholder() && this.input.hasClass('placeholder')) {
this.input.removeClass('placeholder');
this.input[0].value = '';
if (this.isPassword) {
try {
this.input[0].setAttribute('type', 'password');
} catch (e) { }
// Restore focus for Opera and IE
this.input.show();
this.input[0].focus();
}
}
},
valueIsPlaceholder : function() {
return this.input[0].value == this.input.attr('placeholder');
},
handlePassword: function() {
var input = this.input;
input.attr('realType', 'password');
this.isPassword = true;
// IE < 9 doesn't allow changing the type of password inputs
if ($.browser.msie && input[0].outerHTML) {
var fakeHTML = $(input[0].outerHTML.replace(/type=(['"])?password\1/gi, 'type=$1text$1'));
this.fakePassword = fakeHTML.val(input.attr('placeholder')).addClass('placeholder').focus(function() {
input.trigger('focus');
$(this).hide();
});
$(input[0].form).submit(function() {
fakeHTML.remove();
input.show()
});
}
}
};
var NATIVE_SUPPORT = !!("placeholder" in document.createElement( "input" ));
$.fn.placeholder = function() {
return NATIVE_SUPPORT ? this : this.each(function() {
var input = $(this);
var placeholder = new Placeholder(input);
placeholder.show(true);
input.focus(function() {
placeholder.hide();
});
input.blur(function() {
placeholder.show(false);
});
// On page refresh, IE doesn't re-populate user input
// until the window.onload event is fired.
if ($.browser.msie) {
$(window).load(function() {
if(input.val()) {
input.removeClass("placeholder");
}
placeholder.show(true);
});
// What's even worse, the text cursor disappears
// when tabbing between text inputs, here's a fix
input.focus(function() {
if(this.value == "") {
var range = this.createTextRange();
range.collapse(true);
range.moveStart('character', 0);
range.select();
}
});
}
});
}
})(jQuery);

View file

@ -0,0 +1,296 @@
/*
Uploadify v2.1.4
Release Date: November 8, 2010
Copyright (c) 2010 Ronnie Garcia, Travis Nickels
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
*/
if(jQuery)(
function(jQuery){
jQuery.extend(jQuery.fn,{
uploadify:function(options) {
jQuery(this).each(function(){
var settings = jQuery.extend({
id : jQuery(this).attr('id'), // The ID of the object being Uploadified
uploader : 'uploadify.swf', // The path to the uploadify swf file
script : 'uploadify.php', // The path to the uploadify backend upload script
expressInstall : null, // The path to the express install swf file
folder : '', // The path to the upload folder
height : 30, // The height of the flash button
width : 120, // The width of the flash button
cancelImg : 'cancel.png', // The path to the cancel image for the default file queue item container
wmode : 'opaque', // The wmode of the flash file
scriptAccess : 'sameDomain', // Set to "always" to allow script access across domains
fileDataName : 'Filedata', // The name of the file collection object in the backend upload script
method : 'POST', // The method for sending variables to the backend upload script
queueSizeLimit : 999, // The maximum size of the file queue
simUploadLimit : 1, // The number of simultaneous uploads allowed
queueID : false, // The optional ID of the queue container
displayData : 'percentage', // Set to "speed" to show the upload speed in the default queue item
removeCompleted : true, // Set to true if you want the queue items to be removed when a file is done uploading
onInit : function() {}, // Function to run when uploadify is initialized
onSelect : function() {}, // Function to run when a file is selected
onSelectOnce : function() {}, // Function to run once when files are added to the queue
onQueueFull : function() {}, // Function to run when the queue reaches capacity
onCheck : function() {}, // Function to run when script checks for duplicate files on the server
onCancel : function() {}, // Function to run when an item is cleared from the queue
onClearQueue : function() {}, // Function to run when the queue is manually cleared
onError : function() {}, // Function to run when an upload item returns an error
onProgress : function() {}, // Function to run each time the upload progress is updated
onComplete : function() {}, // Function to run when an upload is completed
onAllComplete : function() {} // Function to run when all uploads are completed
}, options);
jQuery(this).data('settings',settings);
var pagePath = location.pathname;
pagePath = pagePath.split('/');
pagePath.pop();
pagePath = pagePath.join('/') + '/';
var data = {};
data.uploadifyID = settings.id;
data.pagepath = pagePath;
if (settings.buttonImg) data.buttonImg = escape(settings.buttonImg);
if (settings.buttonText) data.buttonText = escape(settings.buttonText);
if (settings.rollover) data.rollover = true;
data.script = settings.script;
data.folder = escape(settings.folder);
if (settings.scriptData) {
var scriptDataString = '';
for (var name in settings.scriptData) {
scriptDataString += '&' + name + '=' + settings.scriptData[name];
}
data.scriptData = escape(scriptDataString.substr(1));
}
data.width = settings.width;
data.height = settings.height;
data.wmode = settings.wmode;
data.method = settings.method;
data.queueSizeLimit = settings.queueSizeLimit;
data.simUploadLimit = settings.simUploadLimit;
if (settings.hideButton) data.hideButton = true;
if (settings.fileDesc) data.fileDesc = settings.fileDesc;
if (settings.fileExt) data.fileExt = settings.fileExt;
if (settings.multi) data.multi = true;
if (settings.auto) data.auto = true;
if (settings.sizeLimit) data.sizeLimit = settings.sizeLimit;
if (settings.checkScript) data.checkScript = settings.checkScript;
if (settings.fileDataName) data.fileDataName = settings.fileDataName;
if (settings.queueID) data.queueID = settings.queueID;
if (settings.onInit() !== false) {
jQuery(this).css('display','none');
jQuery(this).after('<div id="' + jQuery(this).attr('id') + 'Uploader"></div>');
swfobject.embedSWF(settings.uploader, settings.id + 'Uploader', settings.width, settings.height, '9.0.24', settings.expressInstall, data, {'quality':'high','wmode':settings.wmode,'allowScriptAccess':settings.scriptAccess},{},function(event) {
if (typeof(settings.onSWFReady) == 'function' && event.success) settings.onSWFReady();
});
if (settings.queueID == false) {
jQuery("#" + jQuery(this).attr('id') + "Uploader").after('<div id="' + jQuery(this).attr('id') + 'Queue" class="uploadifyQueue"></div>');
} else {
jQuery("#" + settings.queueID).addClass('uploadifyQueue');
}
}
if (typeof(settings.onOpen) == 'function') {
jQuery(this).bind("uploadifyOpen", settings.onOpen);
}
jQuery(this).bind("uploadifySelect", {'action': settings.onSelect, 'queueID': settings.queueID}, function(event, ID, fileObj) {
if (event.data.action(event, ID, fileObj) !== false) {
var byteSize = Math.round(fileObj.size / 1024 * 100) * .01;
var suffix = 'KB';
if (byteSize > 1000) {
byteSize = Math.round(byteSize *.001 * 100) * .01;
suffix = 'MB';
}
var sizeParts = byteSize.toString().split('.');
if (sizeParts.length > 1) {
byteSize = sizeParts[0] + '.' + sizeParts[1].substr(0,2);
} else {
byteSize = sizeParts[0];
}
if (fileObj.name.length > 20) {
fileName = fileObj.name.substr(0,20) + '...';
} else {
fileName = fileObj.name;
}
queue = '#' + jQuery(this).attr('id') + 'Queue';
if (event.data.queueID) {
queue = '#' + event.data.queueID;
}
jQuery(queue).append('<div id="' + jQuery(this).attr('id') + ID + '" class="uploadifyQueueItem">\
<div class="cancel">\
<a href="javascript:jQuery(\'#' + jQuery(this).attr('id') + '\').uploadifyCancel(\'' + ID + '\')"><img src="' + settings.cancelImg + '" border="0" /></a>\
</div>\
<span class="fileName">' + fileName + ' (' + byteSize + suffix + ')</span><span class="percentage"></span>\
<div class="uploadifyProgress">\
<div id="' + jQuery(this).attr('id') + ID + 'ProgressBar" class="uploadifyProgressBar"><!--Progress Bar--></div>\
</div>\
</div>');
}
});
jQuery(this).bind("uploadifySelectOnce", {'action': settings.onSelectOnce}, function(event, data) {
event.data.action(event, data);
if (settings.auto) {
if (settings.checkScript) {
jQuery(this).uploadifyUpload(null, false);
} else {
jQuery(this).uploadifyUpload(null, true);
}
}
});
jQuery(this).bind("uploadifyQueueFull", {'action': settings.onQueueFull}, function(event, queueSizeLimit) {
if (event.data.action(event, queueSizeLimit) !== false) {
alert('The queue is full. The max size is ' + queueSizeLimit + '.');
}
});
jQuery(this).bind("uploadifyCheckExist", {'action': settings.onCheck}, function(event, checkScript, fileQueueObj, folder, single) {
var postData = new Object();
postData = fileQueueObj;
postData.folder = (folder.substr(0,1) == '/') ? folder : pagePath + folder;
if (single) {
for (var ID in fileQueueObj) {
var singleFileID = ID;
}
}
jQuery.post(checkScript, postData, function(data) {
for(var key in data) {
if (event.data.action(event, data, key) !== false) {
var replaceFile = confirm("Do you want to replace the file " + data[key] + "?");
if (!replaceFile) {
document.getElementById(jQuery(event.target).attr('id') + 'Uploader').cancelFileUpload(key,true,true);
}
}
}
if (single) {
document.getElementById(jQuery(event.target).attr('id') + 'Uploader').startFileUpload(singleFileID, true);
} else {
document.getElementById(jQuery(event.target).attr('id') + 'Uploader').startFileUpload(null, true);
}
}, "json");
});
jQuery(this).bind("uploadifyCancel", {'action': settings.onCancel}, function(event, ID, fileObj, data, remove, clearFast) {
if (event.data.action(event, ID, fileObj, data, clearFast) !== false) {
if (remove) {
var fadeSpeed = (clearFast == true) ? 0 : 250;
jQuery("#" + jQuery(this).attr('id') + ID).fadeOut(fadeSpeed, function() { jQuery(this).remove() });
}
}
});
jQuery(this).bind("uploadifyClearQueue", {'action': settings.onClearQueue}, function(event, clearFast) {
var queueID = (settings.queueID) ? settings.queueID : jQuery(this).attr('id') + 'Queue';
if (clearFast) {
jQuery("#" + queueID).find('.uploadifyQueueItem').remove();
}
if (event.data.action(event, clearFast) !== false) {
jQuery("#" + queueID).find('.uploadifyQueueItem').each(function() {
var index = jQuery('.uploadifyQueueItem').index(this);
jQuery(this).delay(index * 100).fadeOut(250, function() { jQuery(this).remove() });
});
}
});
var errorArray = [];
jQuery(this).bind("uploadifyError", {'action': settings.onError}, function(event, ID, fileObj, errorObj) {
if (event.data.action(event, ID, fileObj, errorObj) !== false) {
var fileArray = new Array(ID, fileObj, errorObj);
errorArray.push(fileArray);
jQuery("#" + jQuery(this).attr('id') + ID).find('.percentage').text(" - " + errorObj.type + " Error");
jQuery("#" + jQuery(this).attr('id') + ID).find('.uploadifyProgress').hide();
jQuery("#" + jQuery(this).attr('id') + ID).addClass('uploadifyError');
}
});
if (typeof(settings.onUpload) == 'function') {
jQuery(this).bind("uploadifyUpload", settings.onUpload);
}
jQuery(this).bind("uploadifyProgress", {'action': settings.onProgress, 'toDisplay': settings.displayData}, function(event, ID, fileObj, data) {
if (event.data.action(event, ID, fileObj, data) !== false) {
jQuery("#" + jQuery(this).attr('id') + ID + "ProgressBar").animate({'width': data.percentage + '%'},250,function() {
if (data.percentage == 100) {
jQuery(this).closest('.uploadifyProgress').fadeOut(250,function() {jQuery(this).remove()});
}
});
if (event.data.toDisplay == 'percentage') displayData = ' - ' + data.percentage + '%';
if (event.data.toDisplay == 'speed') displayData = ' - ' + data.speed + 'KB/s';
if (event.data.toDisplay == null) displayData = ' ';
jQuery("#" + jQuery(this).attr('id') + ID).find('.percentage').text(displayData);
}
});
jQuery(this).bind("uploadifyComplete", {'action': settings.onComplete}, function(event, ID, fileObj, response, data) {
if (event.data.action(event, ID, fileObj, unescape(response), data) !== false) {
jQuery("#" + jQuery(this).attr('id') + ID).find('.percentage').text(' - Completed');
if (settings.removeCompleted) {
jQuery("#" + jQuery(event.target).attr('id') + ID).fadeOut(250,function() {jQuery(this).remove()});
}
jQuery("#" + jQuery(event.target).attr('id') + ID).addClass('completed');
}
});
if (typeof(settings.onAllComplete) == 'function') {
jQuery(this).bind("uploadifyAllComplete", {'action': settings.onAllComplete}, function(event, data) {
if (event.data.action(event, data) !== false) {
errorArray = [];
}
});
}
});
},
uploadifySettings:function(settingName, settingValue, resetObject) {
var returnValue = false;
jQuery(this).each(function() {
if (settingName == 'scriptData' && settingValue != null) {
if (resetObject) {
var scriptData = settingValue;
} else {
var scriptData = jQuery.extend(jQuery(this).data('settings').scriptData, settingValue);
}
var scriptDataString = '';
for (var name in scriptData) {
scriptDataString += '&' + name + '=' + scriptData[name];
}
settingValue = escape(scriptDataString.substr(1));
}
returnValue = document.getElementById(jQuery(this).attr('id') + 'Uploader').updateSettings(settingName, settingValue);
});
if (settingValue == null) {
if (settingName == 'scriptData') {
var returnSplit = unescape(returnValue).split('&');
var returnObj = new Object();
for (var i = 0; i < returnSplit.length; i++) {
var iSplit = returnSplit[i].split('=');
returnObj[iSplit[0]] = iSplit[1];
}
returnValue = returnObj;
}
}
return returnValue;
},
uploadifyUpload:function(ID,checkComplete) {
jQuery(this).each(function() {
if (!checkComplete) checkComplete = false;
document.getElementById(jQuery(this).attr('id') + 'Uploader').startFileUpload(ID, checkComplete);
});
},
uploadifyCancel:function(ID) {
jQuery(this).each(function() {
document.getElementById(jQuery(this).attr('id') + 'Uploader').cancelFileUpload(ID, true, true, false);
});
},
uploadifyClearQueue:function() {
jQuery(this).each(function() {
document.getElementById(jQuery(this).attr('id') + 'Uploader').clearFileUploadQueue(false);
});
}
})
})(jQuery);

5
public/js/projects.js Normal file
View file

@ -0,0 +1,5 @@
$(function() {
// this space intentionally left blank
})

View file

@ -0,0 +1,40 @@
$(function() {
$('#password').focus()
$('#reset-password-form').submit(function() {
var passwordField = $('#password')
, confirmationField = $('#password-confirmation')
, valid = true
, focused = false
if ($.trim(passwordField.val()).length === 0) {
passwordField.addClass('error').val('').focus()
focused = true
valid = false
}
else {
passwordField.removeClass('error')
}
if (passwordField.val() !== confirmationField.val()) {
confirmationField.addClass('error').val('')
valid = false
if (!focused) {
confirmationField.focus()
focused = true
}
}
else {
confirmationField.removeClass('error')
}
if (valid) {
$('#reset-password-button').hide()
$('#reset-password-spinner').show()
}
return valid
})
})

40
public/js/sign-in.js Normal file
View file

@ -0,0 +1,40 @@
$(function() {
$('input[name="email"]').focus()
$('#forgot-password-link').click(function() {
window.location.href = '/forgot-password/' + $('#email').val()
return false
})
$('#sign-in-form').submit(function() {
var emailField = $('input[name="email"]')
, passwordField = $('input[name="password"]')
, valid = true
, focused = false
if ($.trim(emailField.val()).length === 0) {
emailField.addClass('error').val('').focus()
focused = true
valid = false
}
if ($.trim(passwordField.val()).length === 0) {
passwordField.addClass('error').val('')
valid = false
if (!focused) {
passwordField.focus()
focused = true
}
}
if (valid) {
emailField.removeClass('error')
passwordField.removeClass('error')
$('#sign-in-button').hide()
$('#sign-in-spinner').show()
}
return valid
})
})

59
public/js/sign-up.js Normal file
View file

@ -0,0 +1,59 @@
$(function() {
if (window.SI.errors) {
for (var name in window.SI.errors) {
$('input[name="' + name + '"]').addClass('error')
}
}
var Validators = {
email: window.SI.EmailIsValid
, password_confirmation: function(v) { return v === $('input[name="password"]').val() }
}
// TODO validate password confirmation on each keypress
$('#sign-up-form').submit(function() {
var valid = true
, focused = false
// Presence check
$.each(['first_name', 'last_name', 'email', 'password', 'password_confirmation'], function(i, name) {
var field = $('input[name="' + name + '"]')
, value = $.trim(field.val())
, validator = Validators[name]
if (value.length === 0 || (validator && !validator(value))) {
field.addClass('error')
if (!focused) {
focused = true
field.focus().select()
}
valid = false
}
else {
field.removeClass('error')
}
})
if (!$('input[name="terms"]').attr('checked')) {
valid = false
$('#terms-cell').addClass('error')
}
else {
$('#terms-cell').removeClass('error')
}
if (valid) {
$('#sign-up-button').hide()
$('#sign-up-spinner').show()
}
return valid
})
$('#sign-in-button').click(function() {
$(this).hide()
$('#sign-in-spinner').show()
})
})

4
public/js/swfobject.js Normal file

File diff suppressed because one or more lines are too long

2
public/robots.txt Normal file
View file

@ -0,0 +1,2 @@
User-agent: *
Allow: /

29
public/sitemap.xml Normal file
View file

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset
xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
<!-- created with Free Online Sitemap Generator www.xml-sitemaps.com -->
<url>
<loc>http://example.com/</loc>
<changefreq>daily</changefreq>
</url>
<url>
<loc>http://example.com/sign-up</loc>
<changefreq>daily</changefreq>
</url>
<url>
<loc>http://example.com/sign-in</loc>
<changefreq>daily</changefreq>
</url>
<url>
<loc>http://example.com/contact</loc>
<changefreq>daily</changefreq>
</url>
<url>
<loc>http://example.com/terms</loc>
<changefreq>daily</changefreq>
</url>
</urlset>

BIN
public/uploadify/cancel.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

BIN
public/uploadify/uploadify.swf Executable file

Binary file not shown.

289
test/common.rb Normal file
View file

@ -0,0 +1,289 @@
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
require 'rubygems'
require 'bundler/setup'
require 'json'
gem 'minitest'
require 'minitest/unit'
require 'rack/test'
require 'redis'
require 'mock-pony'
require 'mock-renderer'
require 'simplecov'
SimpleCov.start
ENV['RACK_ENV'] = 'test'
module Stormy
KeyPrefix = 'TEST:' unless const_defined?(:KeyPrefix)
end
require 'stormy'
require 'stormy/server'
module Stormy
module Test
class Unit < MiniTest::Unit
def before_suites
end
def after_suites
# nuke test data
redis = Redis.new
redis.keys(Stormy.key('*')).each do |key|
redis.del key
end
if Pony.sent_mail.length > 0
puts "\nLeftover mail: #{Pony.sent_mail.length}"
Pony.sent_mail.each do |m|
puts
puts "To: #{m[:to]}"
puts "From: #{m[:from]}"
puts "Subject: #{m[:subject]}"
puts "Content type: #{m[:content_type]}"
puts m[:body]
end
end
end
def _run_suites(suites, type)
begin
before_suites
super(suites, type)
ensure
after_suites
end
end
def _run_suite(suite, type)
begin
suite.before_suite if suite.respond_to?(:before_suite)
super(suite, type)
ensure
suite.after_suite if suite.respond_to?(:after_suite)
end
end
end # Unit
#############
### Cases ###
#############
class Case < Unit::TestCase
include Stormy::Models
def redis
@redis ||= Redis.new
end
def fixtures(name)
@_fixtures ||= {}
@_fixtures[name] ||= JSON.parse(File.read(File.join(this_dir, "fixtures", "#{name}.json")))
end
def photo_file(filename)
@_photos ||= {}
@_photos[filename] ||= File.expand_path(File.join(this_dir, "photos", filename))
end
def video_file(filename)
@_videos ||= {}
@_videos[filename] ||= File.expand_path(File.join(this_dir, "videos", filename))
end
def erb(template, options = {}, locals = {})
@renderer ||= MockRenderer.new
@renderer.erb(template, options, locals)
end
private
def this_dir
@_this_dir ||= File.dirname(__FILE__)
end
end # Case
class HelperCase < Case
include Stormy::Helpers::Utils
end # HelperCase
class ControllerCase < Case
include Rack::Test::Methods
def app
Stormy::Server
end
def session
last_request.env['rack.session']
end
def flash
session['flash']
end
def config
@config ||= Stormy::Config.instance
end
def assert_response_json_equal(data)
assert_equal data, JSON.parse(last_response.body)
end
def assert_response_json_ok(data = nil)
assert_ok
data = if data
{ 'status' => 'ok', 'data' => data }
else
{ 'status' => 'ok' }
end
assert_response_json_equal data
end
def assert_response_json_fail(reason = nil)
assert_ok
data = if reason
{ 'status' => 'fail', 'reason' => reason }
else
{ 'status' => 'fail' }
end
assert_response_json_equal data
end
def assert_ok
puts last_response.body unless last_response.ok?
assert last_response.ok?, "expected ok response 2xx, got #{last_response.status}"
end
def assert_bad_request
assert_equal 400, last_response.status
end
def assert_not_authorized
assert_equal 403, last_response.status
end
def assert_not_found
assert_equal 404, last_response.status
end
def assert_redirected(path = nil)
assert_equal 302, last_response.status, "expected 302 redirect, got #{last_response.status} (#{last_response.body})"
if path
url = if path.starts_with?('http')
path
else
"http://example.org#{path}"
end
assert_equal url, last_response.headers['Location']
end
end
end # ControllerCase
###############
### Helpers ###
###############
module Helpers
module Accounts
include Stormy::Models
def accounts
@accounts ||= fixtures('accounts')
end
def setup_accounts
@existing_account_data = accounts['sami']
@existing_account = Account.create(@existing_account_data)
@account_data = accounts['freddy']
end
def teardown_accounts
if @signed_in
@signed_in = false
sign_out
end
Account.list_ids.each do |id|
Account.delete!(id)
end
end
def sign_in(account_data = @existing_account_data, options = {})
post '/sign-in', account_data.merge(options)
@signed_in = true
end
def sign_out
post '/sign-out'
end
end # Accounts
module Admins
include Stormy::Models
def setup_admins
@admin_data ||= fixtures('admins')['sami']
@admin = Admin.create(@admin_data)
end
def teardown_admins
@admin.delete!
end
end # Admins
module Projects
include Stormy::Models
def projects
@projects ||= fixtures('projects')
end
def setup_projects(options = {})
account = options[:owner] || @existing_account
@existing_project_data = projects['stormy']
if account
@existing_project_data['account_id'] = account.id
end
@existing_project = Project.create(@existing_project_data)
@new_project_data = projects['dating-free']
end
def teardown_projects
Project.list_ids.each do |id|
Project.delete!(id)
end
end
end # Projects
end # Helpers
end
end
MiniTest::Unit.runner = Stormy::Test::Unit.new
MiniTest::Unit.autorun

View file

@ -0,0 +1,283 @@
#!/usr/bin/env ruby
#
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
require 'common'
class AccountsControllerTest < Stormy::Test::ControllerCase
include Stormy::Test::Helpers::Accounts
include Stormy::Test::Helpers::Projects
def setup
setup_accounts
end
def teardown
teardown_accounts
end
###############
### Sign Up ###
###############
def test_sign_up
# populate session['source'] and session['source_info']
get '/promo'
get '/sign-up', {}
assert_ok
end
def test_sign_up_with_valid_data
post '/sign-up', @account_data
assert_redirected '/projects'
assert_equal 1, Pony.sent_mail.length
assert mail = Pony.sent_mail.shift
end
def test_sign_up_with_missing_fields
account_data = @account_data.dup
# first name
account_data['first_name'] = nil
post '/sign-up', account_data
assert_redirected '/sign-up'
# last name
account_data['last_name'] = nil
post '/sign-up', account_data
assert_redirected '/sign-up'
# password
account_data['password'] = nil
post '/sign-up', account_data
assert_redirected '/sign-up'
end
def test_sign_up_with_invalid_data
account_data = @account_data.dup
account_data['email'] = 'not an email address'
post '/sign-up', account_data
assert_redirected '/sign-up'
end
def test_sign_up_with_existing_email
post '/sign-up', @existing_account_data
assert_redirected '/sign-up'
end
#####################
### Authorization ###
#####################
def test_sign_in
get '/sign-in'
assert_ok
end
def test_sign_in_submit
sign_in
assert_redirected '/projects'
end
def test_sign_in_remember
sign_in(@existing_account_data, 'remember' => 'on')
assert_redirected '/projects'
post '/sign-out'
assert_redirected '/'
get '/projects'
assert_ok
# deletes remembered cookie
sign_in
end
def test_sign_in_with_invalid_credentials
sign_in(@account_data)
assert_redirected '/sign-in'
end
def test_sign_in_redirect
# authorized page redirects to sign-in
get '/account'
assert_redirected '/sign-in'
# redirects to original URL after signing in
sign_in
assert_redirected '/account'
end
def test_sign_out
post '/sign-out'
assert_redirected '/'
end
def test_forgot_password
get '/forgot-password'
assert_ok
end
def test_forgot_password_existing_email
post '/forgot-password', { :email => @existing_account.email }
assert_redirected '/sign-in'
assert Account.fetch(@existing_account.id).password_reset_token
assert_equal 1, Pony.sent_mail.length
assert mail = Pony.sent_mail.shift
end
def test_forgot_password_non_existent_email
post '/forgot-password', { :email => 'not a real email' }
assert_redirected '/forgot-password'
end
def test_forgot_password_missing_email
post '/forgot-password', { :email => '' }
assert_redirected '/forgot-password'
end
def test_reset_password
email = @existing_account.email
post '/forgot-password', { :email => email }
assert_redirected '/sign-in'
assert_equal 1, Pony.sent_mail.length
assert mail = Pony.sent_mail.shift
token = Account.fetch(@existing_account.id).password_reset_token
get "/sign-in/#{email}/#{token}"
assert_ok
new_password = 'new password'
post '/account/reset-password', { 'password' => new_password }
assert_redirected '/projects'
assert_equal @existing_account.id, Account.check_password(@existing_account.email, new_password)
assert Account.fetch(@existing_account.id).password == new_password
# token is only good for one use
get "/sign-in/#{email}/#{token}"
assert_redirected "/forgot-password/#{email}"
end
###############
### Account ###
###############
def test_account
sign_in
get '/account'
assert_ok
end
def test_account_password
sign_in
new_password = 'my new password'
post '/account/password', {
'old-password' => @existing_account_data['password'],
'new-password' => new_password,
'password-confirmation' => new_password
}
assert_response_json_ok
end
def test_account_password_incorrect
sign_in
post '/account/password', {
'old-password' => 'wrong password',
'new-password' => 'irrelevant',
'password-confirmation' => 'irrelevant'
}
assert_response_json_fail 'incorrect'
end
def test_account_password_invalid
sign_in
post '/account/password', {
'old-password' => @existing_account_data['password'],
'new-password' => ' ',
'password-confirmation' => ' '
}
assert_response_json_fail 'invalid'
end
def test_account_update_json
sign_in
# email
post '/account/update.json', { :id => 'email', :value => @existing_account.email }
# noop, but is ok
assert_response_json_ok
# does not send email verification mail
assert_equal 0, Pony.sent_mail.length
post '/account/update.json', { :id => 'email', :value => 'sami-different@example.com' }
assert_response_json_ok
assert_equal 1, Pony.sent_mail.length
assert mail = Pony.sent_mail.shift
post '/account/update.json', { :id => 'email', :value => 'not an email address' }
assert_response_json_fail 'invalid'
other_account = Account.create(@account_data)
post '/account/update.json', { :id => 'email', :value => other_account.email }
assert_response_json_fail 'taken'
other_account.delete!
end
def test_account_update
sign_in
# valid data
new_phone = '640-555-1234'
post '/account/update', { 'id' => 'phone', 'value' => new_phone }
assert_ok
assert_equal new_phone, last_response.body
# invalid data
post '/account/update', { 'id' => 'first_name', 'value' => '' }
assert_bad_request
# non-updatable fields are ignored, but treated the same as updatable fields from the server's perspective
post '/account/update', { 'id' => 'email_verified', 'value' => 'true' }
assert_ok
assert_equal 'true', last_response.body
end
####################
### Verification ###
####################
def test_verify_email
get "/account/verify/#{@existing_account.email}/#{@existing_account.email_verification_token}"
assert_redirected '/account'
assert_nil Account.fetch(@existing_account.id).email_verification_token
end
def test_verify_email_with_invalid_token_signed_in
sign_in
get "/account/verify/#{@existing_account.email}/not-a-real-token"
assert_redirected '/account'
assert_equal @existing_account.email_verification_token, Account.fetch(@existing_account.id).email_verification_token
end
def test_verify_email_with_invalid_token_not_signed_in
get "/account/verify/#{@existing_account.email}/not-a-real-token"
assert_ok
assert_equal @existing_account.email_verification_token, Account.fetch(@existing_account.id).email_verification_token
end
def test_account_send_email_verification
sign_in
post '/account/send-email-verification'
assert_response_json_ok
assert_equal 1, Pony.sent_mail.length
assert mail = Pony.sent_mail.shift
end
end

View file

@ -0,0 +1,326 @@
#!/usr/bin/env ruby
#
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
require 'common'
class AdminControllerTest < Stormy::Test::ControllerCase
include Stormy::Test::Helpers::Accounts
include Stormy::Test::Helpers::Projects
include Stormy::Helpers::Admin
include Stormy::Helpers::FAQ
include Stormy::Helpers::Utils
def admins
@admins ||= fixtures('admins')
end
def setup
@existing_admin_data = admins['sami']
@existing_admin = Admin.create(@existing_admin_data)
@admin_data = admins['freddy']
end
def teardown
post '/admin/sign-out'
Admin.list_ids.each do |id|
Admin.delete!(id)
end
end
def sign_in(admin = @existing_admin_data)
post '/admin/sign-in', admin
end
#####################
### Sign In & Out ###
#####################
def test_sign_in
get '/admin/sign-in'
assert_ok
end
def test_sign_in_submit
sign_in
assert_redirected '/admin'
end
def test_sign_in_with_invalid_credentials
sign_in(@admin_data)
assert_redirected '/admin/sign-in'
end
def test_sign_in_redirect
sign_in
assert_redirected '/admin'
end
def test_sign_out
post '/admin/sign-out'
assert_redirected '/admin'
end
############################
### Dashboard & Password ###
############################
def test_dashboard
sign_in
get '/admin'
assert_ok
assert last_response.body.match(/<title>Dashboard/)
end
def test_change_password
sign_in
get '/admin/password'
assert_ok
new_password = 'new password'
post '/admin/password', { 'password' => new_password, 'password_confirmation' => new_password }
assert_redirected '/admin'
@existing_admin.reload!
assert @existing_admin.password == new_password
# incorrect confirmation
post '/admin/password', { 'password' => new_password, 'password_confirmation' => 'oops' }
assert_redirected '/admin/password'
end
################
### Accounts ###
################
def test_accounts
sign_in
get '/admin/accounts'
assert_ok
end
def test_account
setup_accounts
sign_in
get '/admin/account/' + @existing_account.email
assert_ok
get '/admin/account/not@an.account'
# this was the previous listing, kind of weird but meh
assert_redirected '/admin/account/' + @existing_account.email
teardown_accounts
end
def test_update_account
setup_accounts
sign_in
# redirected to proper page when changing email addresses
new_email = 'sami-different@example.com'
post '/admin/account/' + @existing_account.email, {
'new_email' => new_email,
'first_name' => 'Samson',
'last_name' => 'Simpson',
'phone' => '+12501234567'
}
assert_redirected '/admin/account/' + new_email
@existing_account.reload!
assert_equal 'Samson', @existing_account.first_name
assert_equal 'Simpson', @existing_account.last_name
assert_equal new_email, @existing_account.email
# email is verified if changed, verification status stays the same if not changed
assert @existing_account.email_verified?
# redirected to dashboard for non-existent email
post '/admin/account/' + @account_data['email']
assert_redirected '/admin'
# redirected to original page if email is taken
@account = Account.create(@account_data)
post '/admin/account/' + @existing_account.email, {
'new_email' => @account.email
}
assert_redirected '/admin/account/' + @existing_account.email
# redirected to account page if fields are invalid
post '/admin/account/' + @existing_account.email, {
'first_name' => '',
'last_name' => '',
'phone' => ''
}
assert_redirected '/admin/account/' + @existing_account.email
# not updated
@existing_account.reload!
assert_equal 'Samson', @existing_account.first_name
assert_equal 'Simpson', @existing_account.last_name
assert_equal '+12501234567', @existing_account.phone
teardown_accounts
end
def test_sign_in_as_user
setup_accounts
sign_in
get '/admin/sign-in-as/' + @existing_account.email
assert_equal @existing_account.id, session[:id]
assert_redirected '/projects'
teardown_accounts
end
def test_delete_account
setup_accounts
setup_projects
sign_in
# make sure the last listing is marked so we are redirected correctly
get '/admin/accounts'
assert_ok
get "/admin/account/#{@existing_account.email}/delete"
assert_redirected '/admin/accounts'
assert_nil Account.fetch(@existing_account.id)
assert_nil Project.fetch(@existing_project.id)
# non-existent accounts are already gone, so no problem
get "/admin/account/nobody@nowhere.net/delete"
# this time the last listing was not marked, so we are redirected to the dashboard
assert_redirected '/admin'
teardown_projects
teardown_accounts
end
################
### Projects ###
################
def test_projects
setup_accounts
setup_projects
sign_in
get '/admin/projects'
assert_ok
teardown_projects
teardown_accounts
end
def test_project
setup_accounts
setup_projects
sign_in
# non-existent project
get '/admin/project/999'
assert_redirected '/admin'
# existing project
get '/admin/project/' + @existing_project.id
assert_ok
teardown_projects
teardown_accounts
end
def test_delete_project
setup_accounts
setup_projects
sign_in
# make sure the last listing is marked so we are redirected correctly
get '/admin/projects'
get "/admin/project/#{@existing_project.id}/delete"
assert_redirected '/admin/projects'
assert_nil Project.fetch(@existing_project.id)
teardown_projects
teardown_accounts
end
###########
### FAQ ###
###########
def test_faq
sign_in
get '/admin/faq'
assert_ok
end
def test_update_faq
sign_in
original_faq = faq
new_faq = 'this is the new faq'
post '/admin/faq', 'faq' => new_faq
assert_redirected '/admin/faq'
assert_equal new_faq, faq
# restore the original value
self.faq = original_faq
end
######################
### Admin Accounts ###
######################
def test_admins
sign_in
get '/admin/admins'
assert_ok
end
def test_add_admin
sign_in
password = 'password'
fields = {
'name' => 'Freddy Kruger',
'email' => 'freddy@example.com',
'password' => password,
'password_confirmation' => password
}
post '/admin/admins', fields
assert_redirected '/admin/admins'
admin = Admin.fetch_by_email('freddy@example.com')
assert admin.password == password
assert_equal fields['name'], admin.name
assert_equal fields['email'], admin.email
# passwords do not match
fields = {
'name' => 'Jason Vorhees',
'email' => 'jason@example.com',
'password' => 'my password',
'password_confirmation' => 'not the same password'
}
post '/admin/admins', fields
assert_redirected '/admin/admins'
assert_nil Admin.fetch_by_email('jason@example.com')
end
def test_delete_admin
sign_in
get "/admin/admins/#{@existing_admin.id}/delete"
assert_redirected '/admin/admins'
assert_equal 0, Admin.count
end
end

View file

@ -0,0 +1,262 @@
#!/usr/bin/env ruby
#
# Copyright 2011 Beta Street Media
require 'common'
class ProjectsControllerTest < Stormy::Test::ControllerCase
include Stormy::Test::Helpers::Accounts
include Stormy::Test::Helpers::Admins
include Stormy::Test::Helpers::Projects
include Stormy::Helpers::Authorization
def setup
header 'User-Agent', "rack/test (#{Rack::Test::VERSION})"
setup_accounts
setup_projects
sign_in
@updated_project_data ||= {
:id => @existing_project.id,
:name => 'the super amazing project'
}
end
def teardown
teardown_projects
teardown_accounts
end
def create_other_account_and_project
@other_account = Account.create(@account_data)
@new_project_data['account_id'] = @other_account.id
@other_project = Project.create(@new_project_data)
end
def photo_filenames
@photo_filenames ||= Dir[photo_file('*.jpg')]
end
def add_photo(filename = photo_filenames.first)
post '/project/add-photo', {
:id => @existing_project.id,
:photo => Rack::Test::UploadedFile.new(filename, 'image/jpeg')
}
@existing_project.reload!
photo_id = @existing_project.photo_ids.last
assert_response_json_ok(
'n' => @existing_project.count_photos,
'photo' => {
'id' => photo_id,
'url' => @existing_project.photo_url(photo_id)
}
)
end
def add_all_photos
photo_filenames.each { |f| add_photo(f) }
end
##################
### Projects ###
##################
def test_projects
# must be authorized
sign_out
get '/projects'
assert_redirected '/sign-in'
# now we can get the projects page
sign_in
get '/projects'
assert_ok
end
def test_project
get "/project/#{@existing_project.id}"
assert_ok
end
def test_project_without_a_name
@existing_project.name = ''
@existing_project.save!
get "/project/#{@existing_project.id}"
assert_ok
end
def test_cannot_access_others_projects
create_other_account_and_project
get "/project/#{@other_project.id}"
assert_redirected '/projects'
follow_redirect!
assert_ok
assert last_response.body.match(/no such project/i)
end
def test_update_project
data = @updated_project_data
post '/project/update', data
assert_redirected "/project/#{data[:id]}"
@existing_project.reload!
data.each do |name, value|
assert_equal value, @existing_project.send(name)
end
end
def test_update_project_with_invalid_fields
expected_name = @existing_project.name
data = {
:id => @existing_project.id,
:name => ''
}
post '/project/update', data
assert_redirected "/project/#{data[:id]}"
@existing_project.reload!
assert_equal expected_name, @existing_project.name
end
def test_update_project_by_admin
setup_admins
post '/admin/sign-in', @admin_data
data = @updated_project_data
post '/project/update', data
assert_redirected "/project/#{data[:id]}"
teardown_admins
end
def test_cannot_update_others_projects
create_other_account_and_project
post '/project/update', { :id => @other_project.id }
assert_redirected '/projects'
follow_redirect!
assert_ok
assert last_response.body.match(/no such project/i)
end
def test_add_photo
# also test /uploadify which is used for photo uploads in IE
%w[/project/add-photo /uploadify].each_with_index do |path, i|
post path, {
:id => @existing_project.id,
# /project/add-photo
:photo => Rack::Test::UploadedFile.new(photo_filenames.first, 'image/jpeg'),
# /uploadify
:Filedata => Rack::Test::UploadedFile.new(photo_filenames.first, 'image/jpeg')
}
@existing_project.reload!
photo_id = @existing_project.photo_ids[i]
assert_response_json_ok({
'n' => i + 1,
'photo' => {
'id' => photo_id,
'url' => @existing_project.photo_url(photo_id)
}
})
end
end
def test_add_photo_fails_at_photo_limit
Project::MaxPhotos.times { add_photo }
post '/project/add-photo', {
:id => @existing_project.id,
:photo => Rack::Test::UploadedFile.new(photo_filenames.first, 'image/jpeg'),
}
assert_response_json_fail('limit')
post '/uploadify', {
:id => @existing_project.id,
:Filedata => Rack::Test::UploadedFile.new(photo_filenames.first, 'image/jpeg'),
}
assert_bad_request
end
def test_add_photo_by_admin
setup_admins
post '/admin/sign-in', @admin_data
post '/project/add-photo', {
:id => @existing_project.id,
:photo => Rack::Test::UploadedFile.new(photo_filenames.first, 'image/jpeg'),
}
@existing_project.reload!
photo_id = @existing_project.photo_ids.last
assert_response_json_ok({
'n' => 1,
'photo' => {
'id' => photo_id,
'url' => @existing_project.photo_url(photo_id)
}
})
teardown_admins
end
def test_remove_photo
add_photo
photo_id = @existing_project.photo_ids.last
post '/project/remove-photo', {
:id => @existing_project.id,
:photo_id => photo_id
}
@existing_project.reload!
assert_response_json_ok('photos' => [])
assert_equal 0, @existing_project.count_photos
end
def test_remove_photo_by_admin
setup_admins
post '/admin/sign-in', @admin_data
add_photo
photo_id = @existing_project.photo_ids.last
post '/project/remove-photo', {
:id => @existing_project.id,
:photo_id => photo_id
}
@existing_project.reload!
assert_response_json_ok('photos' => [])
assert_equal 0, @existing_project.count_photos
teardown_admins
end
def test_reorder_photos
add_all_photos
@existing_project.reload!
photo_ids = @existing_project.photo_ids
# move the first to the end
photo_ids.push(photo_ids.shift)
post '/project/photo-order', {
:id => @existing_project.id,
:order => photo_ids
}
@existing_project.reload!
assert_equal photo_ids, @existing_project.photo_ids
end
def test_reorder_photos_by_admin
setup_admins
post '/admin/sign-in', @admin_data
add_all_photos
@existing_project.reload!
photo_ids = @existing_project.photo_ids
# move the first to the end
photo_ids.push(photo_ids.shift)
post '/project/photo-order', {
:id => @existing_project.id,
:order => photo_ids
}
@existing_project.reload!
assert_equal photo_ids, @existing_project.photo_ids
teardown_admins
end
end

View file

@ -0,0 +1,36 @@
#!/usr/bin/env ruby
#
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
require 'common'
class PublicControllerTest < Stormy::Test::ControllerCase
def test_home
get '/'
assert_ok
end
def test_contact
get '/contact'
assert_ok
end
def test_contact_form
post '/contact', { 'email' => 'sami@example.com', 'message' => 'please get back to me...' }
assert_redirected '/contact'
assert_equal 1, Pony.sent_mail.length
assert mail = Pony.sent_mail.shift
end
def test_terms
get '/terms'
assert_ok
end
def test_faq
get '/faq'
assert_ok
end
end

16
test/fixtures/accounts.json vendored Normal file
View file

@ -0,0 +1,16 @@
{ "sami": {
"first_name" : "Sami"
, "last_name" : "Samhuri"
, "email" : "sami@example.com"
, "phone" : "250-216-6216"
, "password" : "super secret"
}
, "freddy": {
"first_name" : "Freddy"
, "last_name" : "Kruger"
, "email" : "freddy@example.com"
, "phone" : "(250) 555-9999"
, "password" : "even more secreter"
}
}

12
test/fixtures/admins.json vendored Normal file
View file

@ -0,0 +1,12 @@
{ "sami": {
"name" : "Sami"
, "email" : "sami@example.com"
, "password" : "super secret"
}
, "freddy": {
"name" : "Freddy"
, "email" : "freddy@example.com"
, "password" : "even more secreter"
}
}

14
test/fixtures/projects.json vendored Normal file
View file

@ -0,0 +1,14 @@
{ "stormy": {
"name" : "Stormy Weather"
}
, "apps-for-you" : {
"name" : "Apps For You"
, "funded_timestamp" : 1328475981
}
, "dating-free" : {
"name" : "Dating Free"
, "fizzled_timestamp" : 1328475981
}
}

View file

@ -0,0 +1,52 @@
#!/usr/bin/env ruby
#
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
require 'common'
class AccountsHelperTest < Stormy::Test::HelperCase
include Stormy::Helpers::Accounts
include Stormy::Test::Helpers::Accounts
include Stormy::Test::Helpers::Projects
def setup
setup_accounts
end
def teardown
teardown_accounts
end
######################
### Password Reset ###
######################
def test_send_reset_password_mail
data = send_reset_password_mail(@existing_account.email)
@existing_account.reload!
assert data
assert_equal @existing_account.first_name, data['name']
assert_equal @existing_account.password_reset_token, data['token']
assert_equal 1, Pony.sent_mail.length
assert mail = Pony.sent_mail.shift
end
####################
### Verification ###
####################
def test_send_verification_mail
send_verification_mail(@existing_account)
assert @existing_account.email_verification_token.present?
assert_equal 1, Pony.sent_mail.length
assert mail = Pony.sent_mail.shift
send_verification_mail(@existing_account, 'custom subject')
assert_equal 1, Pony.sent_mail.length
assert mail = Pony.sent_mail.shift
end
end

View file

@ -0,0 +1,39 @@
#!/usr/bin/env ruby
#
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
require 'common'
class AdminHelperTest < Stormy::Test::HelperCase
include Stormy::Helpers::Admin
def session
@session ||= {}
end
def test_num_accounts
assert_equal 0, num_accounts
end
def test_num_admins
assert_equal 0, num_admins
end
def test_num_projects
assert_equal 0, num_projects
end
def test_last_listing
assert_equal '/admin', last_listing
mark_last_listing '/admin/accounts'
assert_equal '/admin/accounts', last_listing
end
def test_mark_last_listing
assert_equal '/admin', last_listing
mark_last_listing '/admin/accounts'
assert_equal '/admin/accounts', last_listing
end
end

Some files were not shown because too many files have changed in this diff Show more